アーティスト・クリエイターの廣川政樹です。2014年から個人で iOS アプリを作り続けてきて、累計5,000万ダウンロードを超えた頃から、Apple Search Ads(ASA)の運用が静かに重くなっていました。手動でキーワード入札を眺めて止める/伸ばすを判断する作業は、平日朝の30分を確実に奪っていきます。これを Antigravity のサブエージェントに任せられないかと、3週間ほど実装と運用を試した記録を残しておきます。
結論を先に書くと、ASA Campaign Management API は意外と素直で、Antigravity の Agent View からスクリプト型のサブエージェントを呼ぶだけで、入札の8割は自律判断に置き換えられました。ただし「自律」と「暴走」の境目は薄いので、Crashlytics 側のクラッシュ率を見て自動停止する安全弁を組み込むまで、本番には入れない方が良いというのが私の所感です。
ASA Campaign Management API に手を出すまでに通った道
最初に Apple Search Ads を有効化したのは2019年で、当時は管理画面のみでキーワードを足したり止めたりしていました。アプリ数が増え、AdMob 側の eCPM と ASA 側の CPI を週次で照らし合わせて手で計算する状況になると、毎週の判断遅れがそのまま無駄な入札に変わります。
API 自体は数年前から提供されていましたが、トークンの取り回しが面倒で、asa-tools のような既存ツールに頼っていました。Antigravity の Browser Agent と Background Agent を別記事で書いた頃から、「もしや、エージェントが定期的にレポートを読みに行って判断するだけで足りるのでは」と考え直し、自前のサブエージェントを書き始めました。
このときに自分に課したルールは2つです。1つは「人間が朝に必ず確認する」こと、もう1つは「異常時は人間より先にエージェントが止める」こと。個人開発でエージェントを動かすときは、止める判断のほうを優先する というのが、5,000万 DL 規模を一人で見てきて辿り着いた感覚です。
ASA を Antigravity に任せる前に整える3つの前提
エージェントを動かす前に整えておくと、後の事故が大きく減ります。
キーワードを「テーマ別キャンペーン」に整理する — 1キャンペーンに無関係なキーワードを混ぜると、エージェントが判断材料を取りづらくなります。私は「壁紙系」「癒し系」「引き寄せ系」という主要3テーマで分けています。
コンバージョン定義を明確にする — ASA は「インストール」を計測しますが、本当の評価軸は Day1 / Day7 リテンションと AdMob 収益、課金率です。Firebase 側で asa_install イベントを別に張り、AdMob 収益と紐づける必要があります。
API 鍵を Antigravity が触らないところに置く — Antigravity のサブエージェントは、api_token を生成する短命プロセスとは別に走らせ、鍵そのものはローカルの Keychain か 1Password CLI に寄せます。鍵を Agent のコンテキストに渡さないことが、後述の安全弁よりも大事です。
この3つが整っていない状態でエージェントを走らせると、無関係なキーワードに過剰入札したり、低 LTV のキーワードを伸ばしてしまうケースが起きやすいです。
JWT 認証と短命トークン更新フローの実装
ASA Campaign Management API は、Apple 提供の公開鍵で署名された JWT を client_assertion として渡し、アクセストークンを取得する OAuth Client Credentials 形式です。最初は curl で組んで詰まりましたが、最終的に Node.js の小さなスクリプトに収まりました。
// asa_token.mjs — Antigravity サブエージェントから子プロセスとして呼ぶ
import { SignJWT, importPKCS8 } from 'jose' ;
const CLIENT_ID = process.env. ASA_CLIENT_ID ;
const TEAM_ID = process.env. ASA_TEAM_ID ;
const KEY_ID = process.env. ASA_KEY_ID ;
const PRIVATE_KEY = await importPKCS8 (
process.env. ASA_PRIVATE_KEY ,
'ES256'
);
async function getAccessToken () {
const now = Math. floor (Date. now () / 1000 );
const assertion = await new SignJWT ({})
. setProtectedHeader ({ alg: 'ES256' , kid: KEY_ID })
. setIssuer ( TEAM_ID )
. setSubject ( CLIENT_ID )
. setAudience ( 'https://appleid.apple.com' )
. setIssuedAt (now)
. setExpirationTime (now + 86400 * 180 ) // 最大 180 日
. sign ( PRIVATE_KEY );
const res = await fetch ( 'https://appleid.apple.com/auth/oauth2/token' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/x-www-form-urlencoded' },
body: new URLSearchParams ({
client_id: CLIENT_ID ,
client_secret: assertion,
grant_type: 'client_credentials' ,
scope: 'searchadsorg' ,
}),
});
if ( ! res.ok) throw new Error ( `ASA token failed: ${ res . status }` );
return res. json (); // { access_token, expires_in: 3600, token_type: "Bearer" }
}
export { getAccessToken };
最初の実装ではアクセストークンを毎回取り直していて、1日あたり 300 回ほどの不要な認証リクエストが発生していました。Antigravity に「このスクリプトのキャッシュ戦略を直してほしい」と Agent View からタスクを投げたら、expires_in - 60 秒のローカルメモリキャッシュを提案してきたので、それを採用しました。ここを直すだけで、ASA への 401 エラーが週に1〜2件発生していたのが完全に消えた のが地味に効きました。
なお、JWT の exp は最大180日ですが、私は 60日で再発行するスクリプト を別の Antigravity の Background Agent で月初に走らせています。鍵更新を忘れると土曜の朝5時に全エージェントが沈黙する、というハマり方を一度しているので、自分のためのリマインダーです。
ここで一度、JWT 認証で本当にハマる細かい落とし穴を 3 つ挙げておきます。私自身、各ハマりに半日ずつ費やしました。
scope: 'searchadsorg' を忘れる — ASA は他の Apple API と異なり、トークン取得時にこの scope が必須です。Apple のドキュメントには小さく書かれていますが、scope を渡さないと取得自体は成功して、後段の API 呼び出しで突然 401 が出ます。デバッグが面倒な落とし穴です。
X-AP-Context ヘッダーの orgId を忘れる — 1 つの Apple ID で複数組織を管理している場合、このヘッダーがないと 200 OK で空配列が返ります。空配列はエージェントから見ると「全キーワードがゼロインパクト」に見えるので、誤って全停止判断につながる危険があります。
expires_in: 3600 を信用しすぎる — 実測では 50 分台で 401 に変わるケースがありました。安全側に倒すなら、expires_in - 300 秒で再取得するキャッシュ戦略にしておくと、エージェントが朝動くタイミングで詰まりません。
これら 3 つを asa_token.mjs のコメントに書き残してあります。次に Antigravity に修正を依頼するときも、エージェントがこのコメントを読んで自然に守ってくれます。
キーワードレポートをサブエージェントが読みに行く設計
ASA Campaign Management API の GET /api/v5/reports/campaigns は、CSV ではなく JSON で柔軟に集計を返してくれます。私のサブエージェントは、毎朝 6:00 JST に過去 7日分のキーワード単位レポートを取得し、Antigravity の agent_workspace に置いてから判断ロジックを走らせます。
// asa_keyword_report.ts
import { getAccessToken } from './asa_token.mjs' ;
type KeywordRow = {
keywordId : number ;
keyword : string ;
matchType : 'EXACT' | 'BROAD' ;
impressions : number ;
taps : number ;
installs : number ;
newDownloads : number ;
reDownloads : number ;
localSpend : { amount : number ; currency : string };
averageCpa : { amount : number };
averageCpt : { amount : number };
ttr : number ; // tap-through-rate
conversionRate : number ;
};
export async function fetchKeywordReport ( campaignId : number , days = 7 ) {
const { access_token } = await getAccessToken ();
const endDate = new Date ();
const startDate = new Date (Date. now () - days * 86400_000 );
const body = {
startTime: startDate. toISOString (). slice ( 0 , 10 ),
endTime: endDate. toISOString (). slice ( 0 , 10 ),
selector: {
orderBy: [{ field: 'localSpend' , sortOrder: 'DESCENDING' }],
pagination: { offset: 0 , limit: 1000 },
},
groupBy: [ 'keywordId' ],
timeZone: 'ORTZ' ,
returnRecordsWithNoMetrics: false ,
};
const res = await fetch (
`https://api.searchads.apple.com/api/v5/reports/campaigns/${ campaignId }/keywords` ,
{
method: 'POST' ,
headers: {
'Authorization' : `Bearer ${ access_token }` ,
'X-AP-Context' : `orgId=${ process . env . ASA_ORG_ID }` ,
'Content-Type' : 'application/json' ,
},
body: JSON . stringify (body),
}
);
if ( ! res.ok) throw new Error ( `ASA report ${ campaignId } failed: ${ res . status }` );
const json = await res. json ();
return json.data.reportingDataResponse.row as Array <{
metadata : KeywordRow ;
other : { granularity ?: unknown [] };
}>;
}
ここまではただの API クライアントですが、Antigravity 側で エージェントに JSON を渡すとき、生データのまま投げない という運用に落ち着きました。Top 50 件の高出費キーワード と Bottom 20 件の低出費キーワード に絞り、各行に「直近 7 日の CPI」「ROAS 推定値」「先週比のインストール伸び率」を計算して付与します。エージェントが扱える情報量に揃えることで、判断のブレが小さくなります。
ROAS と CPI から入札を動かすポリシーの作り方
肝心の判断ロジックです。最初は「ChatGPT に投げて自然言語で判断させる」ように作りましたが、3日目に1キーワードに対して bidAmount.amount: 999.99 の入札を Antigravity が提案してきて青ざめました。原因は、レポートの通貨単位を JPY から USD に変えたあと、エージェントが旧プロンプトを参照していたからです。
それ以来、判断ロジックは決定論的なコードに寄せ、エージェントには「異常検知」と「説明文の生成」だけ任せる 方針に変えました。これが個人開発のサイズ感には合っています。
# bid_policy.py — 決定論的入札調整ロジック
from dataclasses import dataclass
@dataclass
class BidDecision :
keyword_id: int
action: str # "pause" | "decrease" | "increase" | "hold"
new_bid_amount: float | None
reason: str
def decide_bid (row, target_cpi: float , target_roas: float ) -> BidDecision:
cpi = row[ 'averageCpa' ][ 'amount' ]
spend = row[ 'localSpend' ][ 'amount' ]
installs = row[ 'installs' ]
estimated_roas = row.get( 'estimated_roas' , 0.0 )
current_bid = row[ 'bidAmount' ][ 'amount' ]
# --- 安全弁:1日 ¥3,000 以上溶けて0インストールなら即停止 ---
if spend >= 3000 and installs == 0 :
return BidDecision(row[ 'keywordId' ], 'pause' , None ,
'7日支出>=¥3000 / installs=0' )
# --- ROAS が目標の 50% 未満:減額 ---
if installs >= 5 and estimated_roas < target_roas * 0.5 :
return BidDecision(row[ 'keywordId' ], 'decrease' ,
round (current_bid * 0.7 , 2 ),
f 'ROAS { estimated_roas :.2f } < target { target_roas :.2f } *0.5' )
# --- CPI が目標の 1.3 倍超:減額 ---
if installs >= 10 and cpi > target_cpi * 1.3 :
return BidDecision(row[ 'keywordId' ], 'decrease' ,
round (current_bid * 0.85 , 2 ),
f 'CPI { cpi :.0f } > target { target_cpi :.0f } *1.3' )
# --- 全条件クリアで ROAS が目標を超えている:増額(上限あり)---
if installs >= 10 and estimated_roas >= target_roas * 1.1 :
next_bid = min ( round (current_bid * 1.15 , 2 ), current_bid + 50 )
return BidDecision(row[ 'keywordId' ], 'increase' , next_bid,
f 'ROAS { estimated_roas :.2f } >= target*1.1' )
return BidDecision(row[ 'keywordId' ], 'hold' , None , 'within tolerance' )
このコードは「動かすルール」を全部読める長さに収めるのを優先しました。私が個人開発で運用するときの推奨は、ルール表が紙1枚に収まること です。エージェントに任せた瞬間、ブラックボックスが3つ重なると、何が起きてもデバッグできなくなります。
ポリシー実行後は、Antigravity のエージェントには「この BidDecision のリストを、人間が読んで30秒で判断できる Slack メッセージに整形してください」とだけ依頼します。判断はコード、説明はエージェント、という分担です。
Search Match と Exact Match の切り替えを判断する基準
ASA の Search Match を有効にすると、Apple 側が自動でマッチするクエリを拾ってくれます。便利ですが、Exact Match のキャンペーンと併走させると、自社キーワードの予算が Search Match 側に食われる現象が起きます。
Antigravity サブエージェントには、以下のような Search Term レポートも別に読みに行かせています。
{
"search_term" : "癒し 壁紙 シンプル" ,
"match_type_used" : "BROAD" ,
"impressions" : 4521 ,
"taps" : 132 ,
"installs" : 18 ,
"cpi" : 285
}
ここで match_type_used: BROAD のクエリで実際に成果が出ているものは、Exact Match に昇格 して別キャンペーンに移動します。逆に Search Match キャンペーン側で「自社ブランド名」が拾われていたら、それは別キャンペーンの食い合いなので Search Match から除外します。
この判断はルールで自動化していますが、月に1回だけ Antigravity に「直近30日の Search Term レポートをサマリし、昇格候補と除外候補を提案してください」と聞いて、私が最終承認する形にしています。月初の朝に5分で済むようになりました。
入札停止の安全弁としての Crashlytics 連動
ここからが、個人開発でエージェントを安心して走らせるための核心です。広告を増やしてインストールが伸びても、直近のビルドがクラッシュしていたら、新規ユーザーを獲得すればするほど低評価レビューが増えて App Store の格付けが落ちます 。これは過去に一度、壁紙アプリで起きていて、4.6 から 4.1 まで星が下がるのに2週間かかりませんでした。
そのため、ASA エージェントを動かすときは、Firebase Crashlytics の crash_free_users_percentage を必ず参照します。
# crashlytics_gate.py
CRASH_FREE_FLOOR = 99.4 # 過去6か月の中央値より 0.2pt 下を閾値に
def is_safe_to_bid_up (crash_free_pct: float , daily_active_users: int ) -> bool :
if daily_active_users < 500 :
# DAU が小さい時間帯はノイズが大きい。安全側に倒す
return False
return crash_free_pct >= CRASH_FREE_FLOOR
エージェントの判断ロジックは、is_safe_to_bid_up が False を返したら「増額」アクションを全部「hold」に降格させます。一度この仕組みを入れてからは、ベータ版のクラッシュ率悪化と入札増額が重なる事故は起きていません。
私の所感としては、個人開発における AI エージェント運用は「攻めの判断より退きの判断のほうを賢くする」のが正解 だと思っています。これは2019年に吉祥寺駅上空で光の輪を見たときの「直感が確信に変わる瞬間」の話と似ていて、判断の方向が定まるためには、何より先にノイズを止める仕組みが要ります。
失敗ケース:低 LTV キーワードを止めそびれた話
ここで一度立ち止まって、運用初期にやらかした話を共有させてください。Antigravity のサブエージェントを動かし始めて 2 週目、癒し 待ち受け 系の派生キーワードが思いのほか伸びて、CPI ¥180 前後で連日 30 インストールを稼いでいました。表面の数字だけ見ると優秀で、エージェントは粛々と入札を ¥210 まで増額していました。
ところが Firebase Analytics 側を 3 日後に確認すると、Day7 リテンションが 4.8% しかありません。私の壁紙アプリの中央値が Day7 で 21% 前後ですから、4 分の 1 以下です。AdMob 収益も追いついていなくて、推定 ROAS は 0.12 程度。日次で ¥6,000 が静かに溶けていました。
何が悪かったかというと、BidDecision の判定で estimated_roas を Antigravity に渡すときに、前日 1 日分の AdMob 収益しか入れていなかった ことです。新規ユーザーは初日にあまり広告を見ないので、ROAS が低く見えるはずなのに、私のスクリプトはインストール直後 24 時間の収益だけで割っていました。割る分母を Day7 累計に直し、当該キーワードを pause させたら、3 日で日次支出が ¥6,000 → ¥800 まで落ち着きました。
この経験で学んだ推奨は、ROAS を計算する分母と分子は、必ず同じ時間窓に揃える ことです。当たり前に聞こえますが、エージェントに数字を渡し始めると、データソース間の時間窓ズレが事故の温床になります。私の場合、Antigravity の Background Agent に「分母と分子の時間窓が一致していない場合、増額判断は無条件で hold にしてください」という追加ルールをコードレベルで入れました。判断側の AI に守らせるより、データ側で防御する方が安全です。
もう 1 つ、低 LTV キーワードを早く検出するための工夫として、インストール後 24 時間の課金率 0% が 3 日連続したキーワードは自動 pause にしました。これは Day7 を待たずに止められるので、損失を 1 日分に閉じ込められます。本番運用で初めて入れたとき、想定外に 4 キーワードが一気に止まり、月次支出が ¥18,000 ほど浮いたのを覚えています。
ASA × AdMob × StoreKit 2 を Antigravity で見渡す
ここまで ASA のキーワード入札だけに絞って書きましたが、個人開発の現場では入札判断と AdMob の eCPM、StoreKit 2 のサブスク継続率を同じ画面で見たくなります。Antigravity の良いところは、サブエージェントを束ねる Background Agent View から、別々のデータソースの結果をひとつのレポートに集約できることです。
私の運用では、毎週月曜の朝に以下を 1 通の Slack メッセージにまとめています。
[Week Report 2026-05 W3]
- ASA: spend ¥84,200 / installs 412 / blended_cpi ¥204
- AdMob: ARPDAU $0.18 / eCPM $9.40 / daily_revenue avg ¥9,300
- StoreKit2: new_subs 38 / churn 6 / MRR delta +¥4,800
- Crashlytics: crash_free 99.62%(前週 99.49%)
- 推奨アクション: 'シンプル 壁紙' を Exact Match に昇格 / 'ASMR 癒し' は Search Match のまま様子見
このレポートを生成しているのは、ASA / AdMob / StoreKit 2 / Crashlytics それぞれの薄い API クライアントと、それらを呼んで結果をマージする 1 つの Antigravity サブエージェントです。各クライアントは 30 〜 60 行程度に収め、エージェント側には「数値の解釈」と「自然な説明文」だけを任せます。
具体的にどう束ねているかを書いておきます。AdMob 側は Reporting API、StoreKit 2 側は App Store Server API、Crashlytics 側は Firebase Cloud Functions に置いた薄い proxy 経由で参照しています。各クライアントには共通インターフェースを切ってあって、Antigravity のサブエージェントは「fetchWeeklyMetrics(source) を 4 回呼ぶだけ」で済みます。
// metrics_aggregator.ts
type Source = 'asa' | 'admob' | 'storekit2' | 'crashlytics' ;
const SOURCES : Source [] = [ 'asa' , 'admob' , 'storekit2' , 'crashlytics' ];
async function fetchAll () : Promise < Record < Source , unknown >> {
const entries = await Promise . all (
SOURCES . map ( async ( s ) => [s, await fetchWeeklyMetrics (s)] as const )
);
return Object. fromEntries (entries) as Record < Source , unknown >;
}
Promise.all で並列化してあるので、4 API 合計でも 4 秒以内には返ってきます。エージェントを介さずデータを集めるところまでをコードでやり切るのが、私が選んでいる設計です。Antigravity のサブエージェントは、集まった JSON を読んで Slack 用の文章に整形する役だけを担います。これで AI 部分の「ブラックボックス係数」が小さく保てます。
ここでもう一度強調しておくと、エージェントが優秀になるほど、決定論的コードを書く意義が増えます 。AI に判断させる範囲が広がるほど、コードで守る範囲も広がらないと、思わぬ方向に推奨されるからです。両家の祖父が宮大工で、「組み手の精度は一度狂うと全体が傾く」と祖父がよく言っていたのを、コードを書くたびに思い出します。
通貨と地域差を扱うときに気をつけたこと
私のアプリは日本国内だけでなく、米国・台湾・韓国・ブラジル等でもインストールが取れています。ASA Campaign Management API は通貨を明示しないと地域ごとに別単位で返してくるので、エージェントに渡す前に 必ず JPY に正規化 しています。
# fx_normalize.py
RATES = {
'JPY' : 1.0 ,
'USD' : 158.4 ,
'TWD' : 4.9 ,
'KRW' : 0.115 ,
'BRL' : 28.5 ,
}
def to_jpy (amount: float , currency: str ) -> float :
rate = RATES .get(currency)
if rate is None :
raise ValueError ( f 'unknown currency: { currency } ' )
return round (amount * rate, 2 )
レートは毎週月曜に Antigravity の Background Agent が為替 API から取得し、RATES を上書きします。ここでエラーが出たら、為替更新がコケているサイン として Slack に通知が飛ぶようにしています。為替が古いまま入札判断が動くのが一番怖いので、データ更新の失敗を見逃さない仕組みは早めに入れておくのをお勧めします。
地域差で言えば、米国向けキャンペーンの CPI を JPY 換算で目標値と比較する ことには注意が必要です。米国は CPI が日本の 2 〜 3 倍に出ますが、ARPU も同程度に高いことが多いので、CPI だけで判断すると米国の伸びるキーワードを止めてしまいます。私の運用では、地域ごとに target_cpi を別管理するようにし、Antigravity に渡すレポートにも region を付与しています。これだけで、誤停止が体感で月 5 件くらい減りました。
個人開発の小さな現場に AI を入れるときの判断軸
3 週間運用してみて感じた判断軸を、いくつか書き残しておきます。
エージェントは「動かす」より「止める」設計を先に作る 。増額判断より、停止条件をきちんと書く方が個人開発の運用には合います。
エージェントに渡す情報量は意図的に削る 。生の API レスポンスを丸ごと渡すと、推奨の幅が広がりすぎて再現性がなくなります。
判断のうち、コードで書けることは全部コードで書く 。AI には「異常検知」と「説明文生成」を任せる。
毎朝の人間レビューを残す 。Slack 3 ボタンで承認できる粒度に整形しておけば、3 分で運用が回ります。
失敗ケースは記録に残す 。私はこの記事で書いた 癒し 待ち受け の話を、自分の _runbooks/asa.md に「2 週目失敗の経緯」として残しています。同じ失敗を繰り返さないための個人ドキュメントが、結局いちばん効きます。
個人開発の現場で続けられる運用フォーマット
3週間運用してみた現時点での運用は、以下のフォーマットに落ち着いています。
毎朝 6:00 JST : Antigravity の Background Agent が ASA 過去7日のレポートを取得・整形して BidDecision を生成
毎朝 6:15 JST : Crashlytics の crash_free_users_percentage を確認し、is_safe_to_bid_up の結果に応じて増額判断を抑制
毎朝 6:30 JST : Slack に提案メッセージが届く(Antigravity が生成した自然言語の説明付き)
毎朝 7:00 JST : 私が3分以内に「承認」「却下」「保留」を押す(Slack の3ボタン)
承認されたものだけ Antigravity の別サブエージェントが ASA Campaign Management API で適用
月初 10:00 JST : Search Term レポートをサマリし、Exact Match 昇格候補と除外候補を提案
トラブル発生時 : 全エージェントを Antigravity の Agent View から手動停止(ボタン1つ)
このフォーマットの良いところは、人間が朝にやることが3分に圧縮された一方で、何かが起きたときに止める権利は人間が持ち続けている ことです。AdMob 月収が変動する月でも、ASA の入札方針だけは穏やかに動いている、という運用が続いています。
ここまで読んでくださってありがとうございました。Antigravity でアプリ事業のオペレーションを自動化する話は、Crashlytics × Antigravity の連携を扱った別の記事でもう少し細部を書いています。ASA を触り始めたばかりの方は、まず Exact Match のキャンペーンを1本だけ Antigravity の Background Agent に毎日読ませる、という小さな実験から始めるのが安全です。私自身、最初はそこから始めました。