朝起きると、まず App Store Connect を開いて昨日の売上を見て、Google Play Console で評価とクラッシュ率を確認し、Crashlytics で新規クラッシュを覗き、AdMob で eCPM の異常を確認し、Gmail で読者からの問い合わせを処理します。アプリを 6 本並行運営している私のここ数年の朝は、判で押したようにこの順番でした。所要時間を測ったら 30 分から 50 分かかっていて、コーヒーを飲み終える頃には頭の中はすでに今日やるべき改修タスクで埋まっています。
2026 年 4 月から、この朝のルーチンを Claude Agent SDK にまかせて 1 通のダイジェストメールに圧縮しました。本稿はその設計と、30 日間動かしてみて見えてきた失敗パターン、そして AdMob のレポート遅延や Crashlytics の API ペイロード爆発といった現場の対処を、実コードと数値で残しておくものです。アーティスト・クリエイターの廣川政樹(@dolice)として、2014 年から AdMob を主軸に個人開発を続けてきた経験から、「個人開発者が一番削るべき時間は判断ではなく確認だ」という結論にここ数年で行き着きました。本稿はその結論を Claude Agent SDK で実装に落とした記録です。
朝刊エージェントが解く課題と、解かない課題
最初に線を引いておきます。朝刊エージェントが解くのは「複数管理画面を横断する確認時間の圧縮」だけで、判断そのものは私が行います。LLM に「この低評価レビューに返信して」と任せきりにする運用は、過去に App Store の返信ポリシー違反で警告を受けた経験から、いまの自分には合いません。エージェントの役割は、
6 アプリ × 5 ソース = 1 日 30 セルを 1 通のメールに集約する
「いつもと違う数値」だけハイライトする(差分検出)
私が判断する必要があるアイテムだけ「TODO 候補」として末尾に並べる
の 3 点に絞りました。返信文の生成や、Crashlytics の自動 dismiss は意図的に含めていません。アプリ事業の累計 5,000 万ダウンロードを宮大工だった両祖父の「手を動かすことが一つの信心」という感覚に支えられてここまで運営してきましたが、AI に判断ごと託すのは、その感覚に反します。
全体アーキテクチャ — Cloudflare Workers Cron + Agent SDK + 5 ツール
朝 6:50 JST に Cloudflare Workers Cron を起点に、Claude Agent SDK セッションを立ち上げます。エージェントは 5 つのカスタム MCP ツールを順に呼んで結果を蓄積し、最後に整形した Markdown 本文を SendGrid 経由でメール送信します。出力先を Slack にしなかったのは、朝起きて開くのが Gmail の方が習慣として定着していたからで、これは個人開発者の本音としてかなり大きい要素でした。
// worker.ts — Cloudflare Workers の cron トリガー
import { ClaudeAgent } from "@anthropic-ai/agent-sdk" ;
import { tools } from "./digest-tools" ;
export default {
async scheduled ( event : ScheduledEvent , env : Env ) {
const agent = new ClaudeAgent ({
apiKey: env. ANTHROPIC_API_KEY ,
model: "claude-sonnet-4-6" ,
systemPrompt: PERSONA_PROMPT , // 静的、prompt caching 対象
tools,
maxIterations: 12 ,
});
const result = await agent. run ({
task: "本日分の朝刊ダイジェストを生成し、send_email_digest で送付すること。" ,
metadata: { userId: "masaki" , date: today () },
});
console. log ( "digest sent:" , result.summary);
} ,
} ;
maxIterations: 12 は、5 ソースの fetch + 差分検出 + 整形 + メール送信を 1 ループで終わらせる安全弁です。最初は無制限にしていましたが、AdMob レポート未着の日にエージェントがリトライを繰り返して 30 ループ消費した事故があり、固定上限に切り替えました。
5 つのカスタムツール — それぞれの仕様と落とし穴
各ソースは 1 つの MCP ツールに対応します。ツール名・入力・出力スキーマを正しく揃えないと Claude が思わぬ呼び方をするので、Zod でガチガチに定義しています。
1. fetch_app_store_connect_sales
App Store Connect API の salesReports/CONSOLIDATED を昨日 1 日分だけ取得します。XML レスポンスを TypeScript で 5,000 万 DL の運用経験ベースで作った parseAscSalesReport で正規化し、アプリ別の units / proceeds_usd / top_3_countries を返します。
export const fetch_app_store_connect_sales = tool ({
name: "fetch_app_store_connect_sales" ,
description: "昨日分の App Store Connect 売上を全アプリ集計して返す。proceeds は USD。" ,
input: z. object ({ date: z. string (). describe ( "YYYY-MM-DD" ) }),
handler : async ({ date }) => {
const jwt = await mintAppStoreJwt ();
const xml = await fetch ( `https://api.appstoreconnect.apple.com/v1/salesReports?...&filter[reportDate]=${ date }` , {
headers: { Authorization: `Bearer ${ jwt }` },
}). then (( r ) => r. text ());
return parseAscSalesReport (xml);
},
});
落とし穴は 3 つあります。まず JWT の有効期限が 20 分しかなく、Cloudflare Workers の CPU タイマー次第では生成タイミングが噛み合わずに 401 が返ります。mintAppStoreJwt() は呼び出しごとに発行する設計にしました。次に App Store Connect の salesReports は前日分のレポートが UTC で 12:00 ごろまで揃わないため、JST 朝 6:50 に取得すると 1 日ずれた結果が返ります。私は date を「昨日」ではなく「一昨日」で固定し、毎朝届くのは「一昨日のサマリ」と割り切っています。最後に、proceeds を JPY に変換するときは TTM レートを別ツール fetch_fx_rate で当日朝に取って一括計算しないと、複数アプリの集計値が日替わりでブレるという地味な事故が起きました。
2. fetch_play_console_reviews
Google Play Developer API の reviews.list を直近 24 時間分だけ取得します。レビュー本文がそのまま Agent SDK のコンテキストを圧迫するので、初回呼び出しで上位 10 件+ 1〜2 星の全件、それ以外は件数のみ集計します。
const reviews = await playDev.reviews. list ({ packageName, maxResults: 100 });
const oneStarOrTwo = reviews. filter (( r ) => r.rating <= 2 );
const top10 = reviews. sort (( a , b ) => b.timestamp - a.timestamp). slice ( 0 , 10 );
return {
total: reviews. length ,
avg: avgRating (reviews),
surfaced: dedupe ([ ... oneStarOrTwo, ... top10]). map (redactPII),
};
redactPII は本文中のメールアドレス・電話番号・他社アプリ名を伏字にする処理です。最初は省いていましたが、ある日エージェントが Gmail にユーザーの実メールをそのまま転載してしまい、自分のアプリでも個人情報を扱う重みを実感しました。
3. fetch_crashlytics_top_issues
Firebase Crashlytics の REST API はベータのまま GA に上がりきっていない部分があり、私は BigQuery エクスポートを噛ませる構成にしています。前日 24 時間で発生回数上位 5 件と、「新規」フラグの立っているクラッシュは件数に関わらず全件返します。
SELECT issue_id, issue_title, ANY_VALUE( version ) AS version ,
COUNT ( * ) AS occurrences, MIN (event_timestamp) AS first_seen
FROM `crashlytics_pkg_name.crashes_*`
WHERE _TABLE_SUFFIX = FORMAT_DATE( '%Y%m%d' , DATE_SUB(CURRENT_DATE( 'Asia/Tokyo' ), INTERVAL 1 DAY ))
AND fatal = TRUE
GROUP BY issue_id, issue_title
ORDER BY occurrences DESC
LIMIT 5 ;
ツールの戻り値には last_known_action フィールドを足し、Firestore に保存している「このクラッシュはどこまで対応済か」のメモを参照させています。これによりエージェントが「同じクラッシュを毎朝 TODO に上げる」という愚を避けられました。
4. fetch_admob_yesterday_revenue
AdMob は eCPM・fill rate・estimated_earnings を Network Report API で取得します。本ツールには 24 時間遅延に対する自衛策を入れていて、戻り値に report_lag_hours を必ず添えます。エージェントには、report_lag_hours > 6 の場合は「データ未着」と明示せよとプロンプトで指示しています。
5. fetch_inbox_developer_mail
Gmail API で from:noreply@apple.com OR from:googleplay-developer-noreply@google.com OR label:app-support のラベルに前日着信したメールを取得し、件名と冒頭 240 文字だけを返します。本文全量は返さないことで、長文のサポート問い合わせがコンテキスト枠を食い潰す事故を防いでいます。
システムプロンプトの設計 — 5 分 TTL のキャッシュに乗るサイズに削る
Claude Sonnet 4.6 の prompt caching を活かすため、システムプロンプトは厳密に 2 層構成にしています。
const PERSONA_PROMPT : PromptCacheable = [
{
type: "text" ,
text: STATIC_PERSONA , // 約 3,800 トークン
cache_control: { type: "ephemeral" }, // 5 分 TTL に乗せる
},
{
type: "text" ,
text: dynamicPart ( today ()), // 約 200 トークン
},
];
静的部 (STATIC_PERSONA) には、6 アプリのプロダクト名・カテゴリ・想定ユーザー層、過去の重大クラッシュ履歴、AdMob eCPM の平常値レンジ、レビュー対応ポリシー、出力フォーマットの厳密な雛形を入れました。動的部 (dynamicPart) には日付と為替レートだけを置きます。1 日 1 回の cron では普通 prompt cache の TTL(5 分)を超えるので意味がないと最初は考えましたが、実際には差分検出やリトライで同一プロンプトが 2〜4 分以内に再呼び出しされることが多く、30 日間の平均でヒット率 92.3% 、cache_read_input_tokens の累計 4,710,000 トークンに対して通常価格の 1/10 で済みました。月額 API 費は $14.20 、cron 起動が 30 回なので 1 回あたり約 $0.47 です。
整形ロジック — 「いつもと違う」だけを濃く書く
ダイジェストの本文は、エージェントが収集した raw data を format_morning_digest という最後のツール経由で Markdown に変換します。ここは Claude に任せず、確定的なロジックで組みました。理由は 30 日間で誤通知を 3 件出して学んだことで、特に「AdMob の eCPM が 50% 下がった」とエージェントが書いてきた日のうち 1 件は、実は単にレポート遅延で前日分が欠落していただけ、というケースでした。
function formatMorningDigest ( data : AggregatedData ) : string {
const sections : string [] = [];
sections. push ( headerLine (data));
sections. push ( salesSection (data, baselineSalesLast14d)); // 14日平均との差分のみ強調
sections. push ( reviewsSection (data)); // 1-2星と新規低評価のみ
sections. push ( crashlyticsSection (data, dismissedIssues)); // 既に対応済みは除外
sections. push ( adMobSection (data, baselineEcpmByApp)); // 平常値レンジを超えたものだけ
sections. push ( inboxSection (data)); // 件名一覧 + 緊急判定済の本文先頭
sections. push ( todoCandidates (data)); // 私が判断するべき項目
return sections. join ( " \n\n " );
}
ここで Claude に任せたのは todoCandidates の優先順位付けだけです。「クラッシュ率 1.2% 超」「1 星レビュー 3 件以上」「売上が 14 日平均から -30% 以上」などの閾値判定はコード側で固定し、判断の境界線が AI の気分で動かないようにしました。
30 日運用で出た失敗 3 件と、その後の防御策
運用初日から完成形だったわけではありません。実際にメールを読みながら「これは違う」と感じた 3 件の事故と対処を残します。
第 1 に、ツール呼び出しの部分失敗の握り潰し がありました。AdMob だけ 503 を返してきた日に、エージェントは「AdMob: データ取得失敗」と書く代わりに、AdMob のセクションを丸ごと飛ばして送信してしまいました。私は AdMob の欄が空なのを見て「収益ゼロ」と一瞬誤読しました。対処として、エージェントのプロンプトに「各ソースの取得結果は必ず明示すること。失敗は『取得失敗(理由)』として表示せよ」と明文化し、format_morning_digest の側でも各セクションが空なら (no data) を強制挿入する二重防御に変えました。
第 2 に、Crashlytics の API ペイロード爆発 です。新バージョンをリリースした翌朝に新規クラッシュが 187 件発生し、ツールが全件返した結果、エージェントのコンテキストが 180k トークン近く埋まりました。料金はまだ許容範囲でしたが、レスポンスタイムが 45 秒に伸び、Workers の subrequest タイムアウト直前でした。対処として、ツール側で「同一 issue_id でグループ化済の上位 N 件のみ返す + 残件数を別フィールドで添える」設計に切り替え、コンテキスト最大消費を 12k トークン以下に抑えました。
第 3 に、誤通知 3 件 は前述の eCPM 急落誤判定、為替急変日に proceeds が二重換算された事故、Gmail のサポートラベル付け直し作業中に「未読 0 件」が「障害」として通知された件です。いずれも閾値ロジックを Claude ではなくコード側で持つことで再発を抑えました。AI に判断を任せた箇所は再現性が低く、修正コストが高くつくという 2014 年から積み重ねてきた経験則どおりの結果でした。
メール本文の実例 — 5 月某日のサンプル
実際に届いた朝刊メールの一部を、固有情報を伏せて貼っておきます。
🌅 Morning Digest — 2026-05-XX
Cron run #29 / API cost: $0.46 / latency: 11.3s
📊 Sales (一昨日 / USD baseline)
Wallpaper Aurora : 312 units / $94.20 / 平均比 -8%(やや低調)
Healing Sounds Pro : 41 units / $40.59 / 平均比 +12%
Manifestation Diary : 88 units / $79.20 / 平均比 +3%
(他 3 アプリは平常範囲のため省略)
⭐ Reviews
Wallpaper Aurora: 1★レビュー 2 件
"壁紙が固まる" (iOS 18.4, iPhone 14 Pro) — 新規
"広告が多すぎる" — 既知パターン
他アプリは平常範囲
💥 Crashlytics
New issues: 1 件
[Aurora 2.4.1] NSInvalidArgumentException -[NSNull length] (12 occurrences)
Top recurring: なし(dismiss 済のみ)
💰 AdMob
eCPM 平常範囲、特記なし
📬 Inbox (4 件)
- "返金について" from xxxx@xxx.com (緊急度: 中)
- "音楽が止まらない" from xxxx@xxx.com (緊急度: 高 / Healing Sounds)
- (他 2 件は件名のみ)
✅ Today's TODO candidates (Claude の推奨)
1. Aurora の NSNull length クラッシュは新規。再現条件を確認すること
2. Healing Sounds の「音楽が止まらない」問い合わせは BGM 永続化バグの可能性
3. Aurora の低調売上は連休明けの揺り戻しの可能性。1 日様子見を推奨
この体裁に落ち着くまで 3 週間ほどかかりました。最初は絵文字を入れず、すべてのアプリの全数値を並べていましたが、毎朝同じ情報が並ぶとスクロール疲れで肝心の異常を見落とすようになり、絵文字 + 「平常範囲は省略」を入れたところ、読了時間が 4 分から 50 秒前後まで縮みました。
コスト試算 — 30 日間の実数値
30 日運用で発生したコストを置いておきます。個人開発者の方が同じ仕組みを組むときの判断材料になれば。
項目 30 日合計 1 日平均
Claude API(Sonnet 4.6, prompt cache 込み) $14.20 $0.47
Cloudflare Workers Cron 起動 $0.00(Free 枠内) $0.00
BigQuery Crashlytics クエリ $0.31 $0.01
SendGrid 送信 $0.00(Free 枠内) $0.00
合計 $14.51 $0.48
私の場合、朝の 30〜50 分が 50 秒前後の読み時間に圧縮されたので、月 15 時間ほどの可処分時間を作れた計算になります。$14.51 で月 15 時間が手に入るなら、これは個人開発者として迷う余地のない投資でした。離れて暮らす子どもたちと電話する時間や、アート制作で吉祥寺のスタジオに座る時間として、その 15 時間は静かに大きな意味を持っています。
ここから何をするか — 次の 1 歩
このまま運用を続けつつ、次に挑戦しようとしているのは「ダイジェストに対する私の返信メールをエージェントが読み、TODO 候補に対する処理ログ(dismiss / 着手 / 後で)を Firestore に書き戻す」往復ループです。これができれば、翌朝のダイジェストから「既に dismiss 済」が自動で消えていくので、ダイジェスト自体が日々学習されていきます。Claude Agent SDK でメール受信側のフックを書く必要があり、設計を詰めている最中です。
朝刊エージェントを自分のドメインで組んでみたい方は、まず 1 ソース(例えば AdMob だけ)からスタートして、その日のうちに動かしてみるのをお勧めします。完成形を最初から目指すよりも、5 分動かして「メールが 1 通届いた」体験を先に作るほうが、続ける動機がはるかに高くなります。私自身、最初は App Store Connect の売上だけを Slack に流す 30 行のスクリプトから始めました。ここまで読んでくださり、ありがとうございました。同じく早朝に管理画面を順に巡回している個人開発者の方の参考になれば嬉しいです。