import { TerminalBox } from "@/components/article/TerminalBox";
2014 年から個人でアプリ開発を続けてきて、Push 通知ほど「触ると数値が動くけれど、踏みすぎるとアンインストールに直結する」機能はないと感じています。壁紙系・癒し系・引き寄せ系を中心に複数本運用してきて累計 5,000 万ダウンロードに届きましたが、その途中で何度も Push 通知の文面と配信タイミングを変えるたびに、D7 リテンションが上下するのを目の当たりにしました。手作業で A/B を回すのは限界があり、最近は Antigravity Agent に文面候補の生成と週次のレビューを任せる構成へ移しています。その実装と、私自身が踏んだ失敗、そして週次の運用ルーチンまでをここに書き残します。
Push 通知の A/B テスト自体は古くからあるテーマですが、AI エージェントが入ると「次に試すべき仮説」を出してくれる点が大きく違います。逆に言えば、エージェントに任せるからこそ、配信量や評価指標の設計を雑にすると一気に数値が崩れるので、その守りの設計まで含めて書きます。
なぜ私が Push 通知の A/B 運用を Agent に任せ始めたのか
壁紙アプリの運営を続ける中で、毎週の運用作業に占める Push 通知関連の時間が無視できなくなっていました。文面を考え、配信時刻を決め、配信後にオープン率と D7 リテンションを見て次の仮説を立てる。1 本のアプリなら手作業でも回せますが、5 本以上を並行で運用していると、それだけで週 4 時間ほど消えていました。
加えて、私自身は癒し系・引き寄せ系の文面を書くのが得意ではありません。語尾のトーンを少し変えるだけでオープン率が 1.4 倍ほど変わることがあり、毎週のアイデア出しで疲弊していました。Antigravity を本格的に使い始めてからは、過去 12 週分の A/B 結果を読み込ませて「次に試す候補を 5 つ出して」と依頼すると、人間の発想にはない方向の文面を提案してくれることが増えてきました。これが導入の決め手でした。
ただし、Agent に丸投げすると 2 つの罠があります。第 1 に、Agent は配信量の感覚を持たないので、人間が止めなければ過剰配信になりがちです。第 2 に、評価指標を「オープン率だけ」にすると、煽り文面がチャンピオンになって長期的にユーザーが離れます。この 2 点は後段で具体的に書きます。
導入前後で変わった運用負荷
導入前は週 4 時間程度かかっていた Push 通知関連の作業が、現在は約 30 分まで縮まりました。内訳としては、Agent が生成した文面候補のレビューに 15 分、Slack に流れてくる週次レポートの確認とゴーサインに 10 分、配信予約のダブルチェックに 5 分という配分です。空いた時間は新しい壁紙の作画や、AdMob のメディエーション設定の見直しに回せるようになりました。個人開発者にとって時間は最大のコストなので、この差分は実感として大きいです。
全体アーキテクチャ — 文面生成・配信・計測の 3 層
設計の前提として、Push 通知の運用は「文面生成」「配信」「計測」の 3 層に分けると Agent との分担が整理しやすくなります。私の構成では下記のような流れにしています。
文面生成層: Antigravity Agent が週次で 5〜10 個の文面候補を提案します。直近 12 週の A/B 結果と、各アプリの世界観メモを参照します。
配信層: Firebase Cloud Messaging (FCM) の HTTP v1 API を叩く Cloudflare Workers の薄い橋渡しを置き、Antigravity から呼べる MCP として公開しています。
計測層: Firebase Analytics から BigQuery に流したイベントを、週次で Antigravity が SQL で参照します。オープン率・通知後 1 時間アクティブ率・D7 リテンション・AdMob 収益を主要指標としています。
それぞれの層を別々に置くのは、責任範囲を Agent から分離するためです。配信を直接 Agent に持たせると、誤って全ユーザーへ即時送ってしまうリスクがあるので、配信は必ず「予約 → 人間承認 → 実行」の 3 ステップを介します。
構成図(テキストで)
クライアント側の iOS アプリは FCM のトークンを Firestore に保存します。配信ジョブは Cloudflare Workers の Cron Trigger と、Antigravity が手動キックする Webhook の両方を持っています。文面候補は GitHub の YAML ファイルにコミットされ、PR で人間レビューを通った後に配信予約に入ります。この PR ベースの運用は、変更履歴を残せる点と、Agent の暴走を物理的に止められる点で気に入っています。
Firebase Cloud Messaging × Antigravity Agent の接続設計
FCM の HTTP v1 API は OAuth2 のアクセストークンが必要で、Antigravity から直接叩くにはやや手数が多いので、私は Cloudflare Workers に薄いラッパを置いて、Antigravity からは MCP 経由で呼べるようにしています。
Before の素朴な実装はこんな形でした。Service Account キーをローカルの env に直書きして、毎回 token を取得して送る、というスタイルです。
// Before: 個人スクリプトで動かしていた頃
import { JWT } from "google-auth-library" ;
const key = require ( "./service-account.json" ); // ← env に直書きで運用していた
const jwt = new JWT ({
email: key.client_email,
key: key.private_key,
scopes: [ "https://www.googleapis.com/auth/firebase.messaging" ],
});
async function sendPush ( token : string , title : string , body : string ) {
const { access_token } = await jwt. authorize ();
await fetch (
`https://fcm.googleapis.com/v1/projects/${ PROJECT_ID }/messages:send` ,
{
method: "POST" ,
headers: {
Authorization: `Bearer ${ access_token }` ,
"Content-Type" : "application/json" ,
},
body: JSON . stringify ({ message: { token, notification: { title, body } } }),
},
);
}
この実装には 3 つの問題がありました。1 つ目はキー管理がローカル依存で、別 PC からも運用したい個人開発者には向きません。2 つ目はリトライ・レート制御がなく、トークン無効化エラーを握りつぶしていました。3 つ目は配信量を Agent から制御するインタフェースがなく、Agent が暴走すれば全ユーザーへ即時配信してしまう構造でした。
After は Cloudflare Workers にラッパを移し、Antigravity からは MCP として「文面・セグメント・予約時刻」だけを渡せば良い形に変えました。
// After: Cloudflare Workers 上のラッパ(抜粋)
import { Hono } from "hono" ;
const app = new Hono <{ Bindings : Env }>();
// MCP から叩かれるエンドポイント。即時送信はせず、必ず「予約」する
app. post ( "/api/push/schedule" , async ( c ) => {
const auth = c.req. header ( "Authorization" );
if (auth !== `Bearer ${ c . env . AGENT_TOKEN }` ) {
return c. json ({ ok: false , reason: "unauthorized" }, 401 );
}
const payload = await c.req. json ();
const { app_id , segment , title , body , scheduled_at , ab_arm } = payload;
// ガード 1: 配信量の上限
const todayCount = await c.env. PUSH_KV . get ( `count:${ app_id }:${ todayKey () }` );
if ( Number (todayCount ?? 0 ) >= MAX_PUSH_PER_DAY [app_id]) {
return c. json ({ ok: false , reason: "daily_cap_exceeded" }, 429 );
}
// ガード 2: 予約のみ受け付け、即時送信は拒否
const lead = new Date (scheduled_at). getTime () - Date. now ();
if (lead < 30 * 60 * 1000 ) {
return c. json ({ ok: false , reason: "lead_time_too_short" }, 400 );
}
// 予約は D1 に積み、Cron Trigger が拾って FCM へ送る
await c.env. PUSH_DB . prepare (
`INSERT INTO scheduled_pushes (app_id, segment, title, body, scheduled_at, ab_arm, status)
VALUES (?, ?, ?, ?, ?, ?, 'pending')` ,
)
. bind (app_id, segment, title, body, scheduled_at, ab_arm)
. run ();
return c. json ({ ok: true });
});
export default app;
Before/After で大きく変わったのは、配信量と予約時刻のガードが Workers 側に明示的に入った点です。Antigravity がどれだけ「もっと配信しよう」と提案してきても、Workers が物理的に止めてくれます。30 分先未満の予約は拒否しているのは、間違えて即時配信を引き起こさないためです。
MCP 側の最小実装
Antigravity から呼ぶための MCP は薄く保ちます。引数は app_id・segment・title・body・scheduled_at・ab_arm の 6 つだけで、複雑なフィールドは公開しません。Agent には「文面候補を出す」「配信を予約する」だけに専念させ、ユーザーリストやキー管理は Workers 側に閉じ込めるのが安全です。
文面生成: Antigravity Agent への指示書の書き方
文面生成のプロンプトは、私の場合は YAML ファイルでアプリごとに管理しています。Antigravity からは Read ツールで読み込んでから生成に入る運用です。要点は次の 3 つです。
第 1 に、世界観メモを冒頭に置きます。壁紙アプリなら「静かに、押し付けがましくない」、癒し系なら「優しく、断定せず、相手のペースに寄り添う」といった一文を必ず置いています。これがないと、Agent は最近の SNS トレンドを取り込んでしまい、煽り口調に振れがちです。
第 2 に、過去 12 週の A/B チャンピオン文面と敗者文面の両方を並べます。チャンピオンだけ見せると同じパターンばかり出してきて、文面の多様性が枯れます。私の経験では、敗者文面を 5 件、チャンピオンを 5 件並べた状態で「これら以外のトーンで 5 案」と頼むと、新しい角度が出やすいです。
第 3 に、評価指標と禁止表現を明示します。「煽る表現」「数値の誇張」「『今すぐ』『限定』『最後』」を禁止し、評価は「オープン後 1 時間の滞在時間」を最優先にすると伝えます。オープン率だけを評価軸にすると、結局短期最適化に陥ります。
# prompts/wallpaper-app/push-generation.yaml
app_world :
tone : "静かで、押し付けがましくない"
brand_voice : "夜にそっと壁紙を変えたくなる感覚を呼び起こす"
avoid :
- "今すぐ / 限定 / 最後 のような急かす語"
- "数値の誇張(神絵 など)"
- "顔文字や絵文字の過剰使用"
evaluation :
primary : "通知タップ後 60 分以内の active_time_sec の合計"
secondary : "D7 リテンション"
tertiary : "AdMob インプレッション × eCPM"
hard_caps :
- "1 ユーザーあたり 1 日 1 通まで"
- "深夜 23:00-08:00 は配信禁止"
ab_test :
arms_per_week : 4
min_sample_per_arm : 3000
このような YAML を Agent に読ませた上で、「直近 12 週の champions_losers.json を踏まえ、4 案を提案して」と依頼します。出力は GitHub に PR で push し、私がレビューしてマージしてから配信予約に入ります。PR ベースにすると変更履歴が GitHub に残るので、後から「あの週の文面はなぜ強かったのか」を遡れるのが大きいです。
Agent への問いかけ方の小さなコツ
私が最近良いと感じている問いかけは、「過去 4 週で D7 が落ちている世代(D7 が前年同週比 -2pt 以上)に、踏まれなくても気にならない短文を 3 つ」というふうに、対象世代と「踏まれなくても OK」という条件を明示するスタイルです。条件を絞らないと、Agent はオープン率最大化に流れます。
セグメント設計とタイミングの試行
セグメントは深く切りすぎないのが原則です。私の壁紙アプリでは、最終的に「D0-D7」「D8-D30」「D31-D90」「D91+」「課金ユーザー」の 5 セグメントに落ち着きました。これ以上分けると 1 アームあたりのサンプルが足りなくなり、A/B の結論が出にくくなります。
タイミングは「タイムゾーン補正後のローカル時刻」を基準にしています。日本国内ユーザーが 7 割ですが、英語圏ユーザーが 25%、その他 5% という構成のため、UTC ベースで送るとアメリカのユーザーには深夜配信になってしまいます。Workers 側で各ユーザーのローカル時刻を見て、希望時刻帯に当てはまるユーザーだけ抽出する流れにしています。
-- D1 上の scheduled_pushes と users の結合(Cron Trigger 内で実行)
SELECT
u . fcm_token ,
s . title ,
s . body ,
s . ab_arm
FROM scheduled_pushes s
JOIN users u
ON u . segment = s . segment
WHERE s . status = 'pending'
AND s . scheduled_at <= datetime ( 'now' , '+5 minutes' )
AND (
-- ユーザーローカル時刻が 19:00-22:00 の場合のみ送る
CAST (strftime( '%H' , datetime ( 'now' , u . tz_offset || ' hours' )) AS INTEGER )
BETWEEN 19 AND 22
)
LIMIT 5000 ;
タイミング検証で印象に残っているのは、癒し系アプリでは 21:30 前後が圧倒的に強かったことです。仮説としては、就寝前のひと息で開きたい層が多いのではと思っています。一方で壁紙アプリは 18:30-19:30 が強く、帰宅後のスマホタイムに刺さっている感覚があります。アプリの世界観と時間帯はかなり相関するため、Agent が「夜遅い時間」を推してきても、世界観に合わなければ却下するレビューを必ず挟みます。
効果計測 — Firebase Analytics と BigQuery で見る指標
Firebase Analytics のイベントを BigQuery に流すと、A/B アームごとのパフォーマンスを SQL で参照できます。Antigravity には BigQuery への参照権限を付与し、週次で次のような SQL を実行させています。
-- 直近 7 日間の A/B アーム別パフォーマンス
WITH push_events AS (
SELECT
user_pseudo_id,
event_timestamp,
( SELECT value . string_value FROM UNNEST(event_params) WHERE key = 'ab_arm' ) AS ab_arm,
( SELECT value . string_value FROM UNNEST(event_params) WHERE key = 'segment' ) AS segment
FROM `analytics_xxx.events_*`
WHERE event_name = 'push_received'
AND _TABLE_SUFFIX BETWEEN
FORMAT_DATE( '%Y%m%d' , DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY ))
AND FORMAT_DATE( '%Y%m%d' , CURRENT_DATE())
),
opens AS (
SELECT user_pseudo_id, event_timestamp AS open_ts
FROM `analytics_xxx.events_*`
WHERE event_name = 'push_opened'
)
SELECT
p . ab_arm ,
p . segment ,
COUNT ( DISTINCT p . user_pseudo_id ) AS sent_users,
COUNT ( DISTINCT o . user_pseudo_id ) AS opened_users,
SAFE_DIVIDE( COUNT ( DISTINCT o . user_pseudo_id ), COUNT ( DISTINCT p . user_pseudo_id )) AS open_rate
FROM push_events p
LEFT JOIN opens o
ON o . user_pseudo_id = p . user_pseudo_id
AND o . open_ts BETWEEN p . event_timestamp AND p . event_timestamp + 3600 * 1000000
GROUP BY p . ab_arm , p . segment
ORDER BY open_rate DESC ;
このクエリ結果を Slack に流すように Cron で組み、Antigravity が週次レビューで「最弱アームを停止 → 次週の候補を提案」する流れに繋げています。重要なのは、SAFE_DIVIDE で 0 除算ガードを入れることと、open_rate だけで打ち切らずに「open 後 60 分の滞在時間」を必ず併記することです。短期指標で意思決定すると、煽り文面に支配されます。
評価期間と最小サンプル
私の経験則として、1 アームあたり 3000 配信が集まらないと A/B の結論を出さないルールにしています。これより少ないと、たまたま強いセグメントに偏った時に誤った勝者を選びがちです。Antigravity にもこの最小サンプルを YAML で明示しておくと、無理に「勝者宣言」してこなくなります。
過剰配信が招いた D7 リテンション低下と、私が踏んだ失敗
Agent に運用を任せ始めた最初の月、私は痛い失敗をしました。Agent が提案する文面のオープン率が良かったので、「1 ユーザー 1 日 1 通」のキャップを 2 通に緩めてしまったのです。短期のオープン率は上がりましたが、3 週間後に D7 リテンションが 4.8 ポイント落ちていることに気づきました。
原因は、深夜帯にも 2 通目が届いてしまい、通知センターでアプリ通知をオフにされたユーザーが急増したことでした。AdMob のインプレッションも 12% 減り、月間収益で 30 万円弱の機会損失になりました。
復旧手順としては、次の 4 ステップを踏みました。
即時に 1 日 1 通のキャップに戻す。Workers 側のガードを MAX_PUSH_PER_DAY = 1 に書き戻し、デプロイ
直近 2 週で 2 通受け取ったユーザーへの配信を 1 週間停止
通知センターでオフにしたユーザーへ、アプリ内バナーで「設定からプッシュ通知を選び直せます」という静かな案内を表示。煽る文面は避ける
Antigravity のプロンプトに「ハードキャップは絶対に超えない」「キャップの根拠は D7 リテンション保護」と明文化
この経験から、Agent に評価指標を渡す時は「短期 KPI と中期 KPI の両方を渡す」「ハードキャップは Workers 側に物理的に持つ」の 2 つを徹底するようになりました。Agent は短期 KPI に最適化するのが得意ですが、中期 KPI の劣化に気づかせる仕組みを別途用意するのが人間側の責任です。
週次レビュー: Agent と人間で何を分担するか
週次の運用ルーチンは下記のように分けています。
第 1 に、月曜の朝、Antigravity が前週の A/B 結果サマリーと「勝者アーム」「敗者アーム」「来週の候補 5 案」を Slack に投げます。これは Cron で自動実行されます。
第 2 に、私は 30 分かけて Slack のサマリーをレビューします。具体的には、勝者アームの文面が世界観に合っているか、D7 リテンションが落ちていないか、AdMob 収益との相関がおかしくないかを確認します。
第 3 に、来週の候補 5 案を 4 案に絞り、必要であれば 1 案を私自身で書き換えて GitHub に PR を出します。マージすると Workers の Cron が翌週の配信予約を順に組みます。
第 4 に、月末に Antigravity に「直近 4 週の傾向と、来月試したいセグメント変更案」を出させます。これは私自身が考える時の叩き台として使い、最終決定は私がします。
この分担で重要なのは、「配信」「KPI 設計」「ハードキャップ」は必ず人間が握ることです。Agent は文面候補と仮説出しに集中させ、最終決定は人間。これを崩した瞬間に冒頭の失敗が起きると私自身は感じています。個人開発者にとっては、自分のアプリの世界観を Agent より深く理解しているという強みが残っており、その強みを生かす分担が一番落ち着きます。
まだ手を出していない領域と、次に試すこと
最後に、私自身がまだ手を出していない領域と、次に試そうと考えていることを書いておきます。記事の締めとしての位置づけです。
1 つ目は、ユーザー個別のレコメンド型 Push です。現状はセグメント単位でしか配信していませんが、Firestore に各ユーザーの最近見た壁紙履歴を持っているので、Antigravity に「最近 30 日でこのカテゴリを 5 回以上見たユーザーに、関連カテゴリの新着を伝える短文を 1 つ」と依頼する形が実装できそうです。ただし、レコメンド型はプライバシーへの配慮が必要なので、ユーザーに通知文の中で「あなたの履歴から」と伝えるかどうかは設計しどころです。
2 つ目は、サイレント Push を活用した事前ロードです。アプリを開いた瞬間の体感速度を上げるために、配信時刻の少し前にサイレント Push で最新の壁紙を端末側に取り込んでおく構成を検討しています。Antigravity には文面ではなく「事前ロードのバッチ計画」を立てさせる役割になります。
3 つ目は、AdMob の eCPM 階層を見ての配信量調整です。週末は eCPM が下がる傾向があるので、収益効率が良い平日夜に配信量を寄せる、という運用を Antigravity に提案させると、月間収益が安定する可能性があります。これは今四半期内に試す予定です。
実装の参考になれば幸いです。Push 通知は短い文面の中に世界観を込める作業で、運用が成熟するほど Agent との分担が効いてくる領域だと私は感じています。お読みいただきありがとうございました。