import { TerminalBox } from "@/components/article/TerminalBox";
癒し系アプリの通知文をひと言だけ変えた翌週、オープン率は上がっているのに D7 リテンションだけが下を向いていました。月曜の朝に届く週次レポートを眺めながら、「勝った」と思って積み重ねてきた判断のうち、本当に勝っていたのはどれだけあったのだろう、と手が止まったのを覚えています。
個人開発で壁紙系・癒し系・引き寄せ系のアプリを長く運用していますが、Push 通知ほど、触れば数字が動き、踏み込みすぎればアンインストールに直結する機能はほかにありません。手作業で 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 : 2
min_sample_per_arm : 3000
(この arms_per_week は当初 4 にしていました。後述の測定を経て 2 へ下げています。)
このような YAML を Agent に読ませた上で、「直近 12 週の champions_losers.json を踏まえ、5 案を出したうえで 2 案に絞って」と依頼します。出力は 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 が週次レビューで「2 位に有意差で勝てたアームだけを勝者として採用し、届かない週はチャンピオン続投 → 次週の候補を提案」する流れに繋げています。重要なのは、SAFE_DIVIDE で 0 除算ガードを入れることと、open_rate だけで打ち切らずに「open 後 60 分の滞在時間」を必ず併記することです。短期指標で意思決定すると、煽り文面に支配されます。
評価期間と最小サンプル
長いあいだ、1 アームあたり 3,000 配信が集まるまで結論を出さない、というのが私の経験則でした。これより少ないと、たまたま強いセグメントに偏ったときに誤った勝者を選びがちだからです。Antigravity にもこの最小サンプルを YAML で明示しておくと、無理に「勝者宣言」をしてこなくなります。
ただ、この 3,000 という数字の根拠を問われると、私は返事に詰まりました。毎週 4 案を配って、いちばん強かった 1 案を勝者と呼ぶ——その運用が実際どれだけ当たっているのかを、私は一度も確かめずに 1 年以上続けていたのです。
4 案から 1 本を選ぶ運用が、どれだけ当たっているのかを測る
確かめるために、依存のない小さなシミュレータを書いて手元で回しました。ベースのオープン率を 8%、4 案のうち 1 案だけが本当に強いという状況を作り、1 アームあたりの配信数を変えながら「観測でいちばん強かった案が、本当に強い案だったか」を 20 万回数えています。あわせて、勝者の観測値が真の実力をどれだけ上回って見えるか(勝者の過大評価)と、2 位に対して有意差が出た週の割合(宣言率)も取りました。
// push-ab-power.mjs — 依存なし。node push-ab-power.mjs で走ります
const TRIALS = 200000 , BASE = 0.08 ;
const rng = (( s ) => () => (s ^= s << 13 , s >>>= 0 , s ^= s >>> 17 , s ^= s << 5 , s >>>= 0 , s / 4294967296 ))( 20260903 );
const binom = ( n , p ) => {
const u1 = Math. max ( rng (), 1e-12 ), u2 = rng ();
const z = Math. sqrt ( - 2 * Math. log (u1)) * Math. cos ( 2 * Math. PI * u2);
return Math. min (n, Math. max ( 0 , Math. round (n * p + Math. sqrt (n * p * ( 1 - p)) * z)));
};
const z2 = ( x1 , n1 , x2 , n2 ) => {
const p = (x1 + x2) / (n1 + n2), se = Math. sqrt (p * ( 1 - p) * ( 1 / n1 + 1 / n2));
return se ? (x1 / n1 - x2 / n2) / se : 0 ;
};
function run ( arms , n , lift ) {
const truth = Array. from ({ length: arms }, ( _ , i ) => BASE + (i === 0 ? lift : 0 ));
let hit = 0 , curse = 0 , declared = 0 ;
for ( let t = 0 ; t < TRIALS ; t ++ ) {
const obs = truth. map (( p ) => binom (n, p));
const [[, best ], [, second ]] = obs. map (( v , i ) => [v, i]). sort (( a , b ) => b[ 0 ] - a[ 0 ]);
if (best === 0 ) hit ++ ; // 真に強い案は index 0
curse += obs[best] / n - truth[best]; // 勝者の過大評価
if ( z2 (obs[best], n, obs[second], n) >= 1.96 ) declared ++ ; // 2 位に有意差で勝てた週
}
return { pick: hit / TRIALS , curse: (curse / TRIALS ) * 100 , declare: declared / TRIALS };
}
for ( const lift of [ 0.01 , 0.004 , 0 ]) {
console. log ( ` \n 真のリフト +${ ( lift * 100 ). toFixed ( 1 ) }pt` );
for ( const arms of [ 4 , 2 ]) for ( const n of [ 3000 , 6000 , 12000 , 25000 ]) {
const r = run (arms, n, lift);
console. log ( ` arms=${ arms } n=${ String ( n ). padStart ( 5 ) } 的中 ${ ( r . pick * 100 ). toFixed ( 1 ) }%` +
` 過大評価 +${ r . curse . toFixed ( 2 ) }pt 宣言率 ${ ( r . declare * 100 ). toFixed ( 1 ) }%` );
}
}
出た数字が、私には応えました。文面を書き換えて現実に起きる差は、私の手元では絶対値で 0.3〜0.5pt 程度です。その水準(+0.4pt)で、いま運用しているとおりの 4 案 × 3,000 配信を回すと、こうなります。
4 案 × 3,000 配信での的中率は 49.9%。コインを投げるのとほとんど変わりません。しかも勝者の観測値は真の実力を +0.46pt 上回って見えます——追いかけている差そのものより、見かけの上振れのほうが大きいのです。翌週その勝者が沈むのは、文面が飽きられたからではなく、最初から実力ではなかった、というだけの話でした。
もっと苦いのが、4 案すべての実力が同じときの挙動です。
実力差がまったくなくても、毎週の勝者は +0.51pt 良く見えます。つまり私の週次レポートは、何も改善していない週にも改善したように読める形式だった、ということです。Agent が悪いのでも、文面が悪いのでもなく、選び方の設計が最初から上振れを拾う形になっていました。
毎週きちんと数字が動いているように見えていたのは、動いていたからではなく、動いて見える読み方をしていたからかもしれません。それに気づいたのは、運用を始めてずいぶん経ったあとでした。
表を横に読むと、打つべき手も見えます。4 案を 6,000 配信ずつに増やしても的中率は 60.2% にしか届きませんが、案を 2 本に絞れば 3,000 配信のままで 72.0% です。配信数は倍にするとユーザーの負担も倍になりますが、候補を減らすのはこちら側だけで完結します。
増やすべきは配信数ではなく、減らすべきは候補数でした。 アプリを開いてくれる方に負担をかけずに精度を上げられる手が先にあるなら、そちらから使います。
測定に合わせて運用ルールを直す
この結果を受けて、私は 3 点を変えました。第 1 に、週あたりのアームを 4 本から 2 本へ。第 2 に、勝者宣言に「2 位に対する有意差」を条件として足し、満たさない週は既存チャンピオンを続投させます。第 3 に、Antigravity には「勝者なし」を正当な結論として返せることを明示しました。
ab_test :
arms_per_week : 2 # 4 → 2(同じ配信数で的中率 49.9% → 72.0%)
min_sample_per_arm : 3000
decision :
rule : "two_proportion_z"
min_z_vs_runner_up : 1.96
on_tie : "keep_champion" # 有意差がなければ既存チャンピオンを続投
report :
must_include : [ "観測差" , "z 値" , "勝者なしの場合はその旨" ]
判定側の実装も、最大値を拾うだけの 1 行から、引き分けを返せる関数へ書き換えました。
type Arm = { id : string ; sent : number ; opened : number };
export function declareWinner ( arms : Arm [], champion : string ) : { winner : string ; tie : boolean } {
const sorted = [ ... arms]. sort (( a , b ) => b.opened / b.sent - a.opened / a.sent);
const [ top , next ] = sorted;
const p = (top.opened + next.opened) / (top.sent + next.sent);
const se = Math. sqrt (p * ( 1 - p) * ( 1 / top.sent + 1 / next.sent));
const z = se ? (top.opened / top.sent - next.opened / next.sent) / se : 0 ;
// 有意差がなければ勝者を作らない。チャンピオンは据え置き
return z >= 1.96 ? { winner: top.id, tie: false } : { winner: champion, tie: true };
}
宣言率の列が示すとおり、2 案 × 3,000 配信でも「勝者あり」と言えるのは 12 週に 1 回程度です。最初は物足りなく感じましたが、残り 11 週の「変更なし」は判断を放棄した週ではなく、上振れを買わずに済んだ週でした。Slack のレポートに tie: true と出る回数が増えてから、私自身が数字に振り回される感覚は薄れています。
過剰配信が招いた 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 案を 2 案まで絞り、必要であれば 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 との分担が効いてくる領域だと私は感じています。お読みいただきありがとうございました。