Apple Search Ads × Antigravity Agent: Autonomous Keyword Bid Tuning Notes from an Indie iOS Studio
How I delegated Apple Search Ads keyword bidding to Antigravity sub-agents for a 50M-download wallpaper app portfolio. JWT auth, ROAS / CPI bid policy, and a Crashlytics-driven safety net.
My name is Hirokawa, an artist and indie developer. I have been shipping iOS apps as a one-person studio since 2014, and the portfolio recently passed 50 million cumulative downloads. As that grew, Apple Search Ads (ASA) operations quietly turned into a 30-minute morning ritual: staring at keyword spend, pausing things, raising bids by feel. I spent about three weeks moving that ritual into an Antigravity sub-agent, and these are the field notes.
The short version: ASA Campaign Management API is surprisingly approachable, and roughly 80% of the daily decisions can be handed off to a script-style sub-agent invoked from the Antigravity Agent View. The trouble is that "autonomous" and "out of control" sit close together. Until I wired a Crashlytics-driven kill switch into the bidding loop, I would not have put this in production.
Why I stopped touching ASA by hand
I turned ASA on for the first time in 2019, when there was no API to speak of. As my app count climbed and I started reconciling AdMob eCPM against ASA CPI manually each week, the lag between observation and action became expensive. Every Friday review meant five days of stale bids on keywords that should have been paused on Monday.
The API had been around for a while, but the token plumbing was annoying enough that I leaned on community tools. After writing earlier pieces about the Antigravity Browser Agent and Background Agent, I started wondering whether an agent could simply read the reports and recommend bid changes. Two rules I gave myself before writing a line of code: a human inspects everything in the morning, and the agent must be able to pause faster than the human can. For an indie developer running 50M-download apps alone, designing the "stop" button is more important than designing the "go" button.
Three prerequisites before delegating to Antigravity
Three things have to be tidy before the agent earns its keep.
Group keywords into themed campaigns. Mixing unrelated keywords inside one campaign makes the agent's decision surface noisy. I split mine into "wallpaper", "calming / healing", and "manifestation / law-of-attraction" themes — the three columns my AdMob revenue actually breaks down along.
Define conversions beyond installs. ASA measures installs, but the real signal is a blend of Day1 / Day7 retention, AdMob revenue per user, and subscription rate. I attach a asa_install event in Firebase Analytics and join it back to AdMob revenue via a small BigQuery view.
Keep the API key out of the agent's context. The token-minting process runs as a separate short-lived child process; the private key itself lives in macOS Keychain (or 1Password CLI on the laptop I travel with). Never paste the key into agent context windows.
Skipping any of these tends to manifest as the agent recommending bid hikes for keywords with low Day7 retention. That happened to me in week two — more on that below.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Concrete implementation of ASA Campaign Management API JWT auth and token caching driven by an Antigravity sub-agent
✦Deterministic bid policy that adjusts spend based on ROAS and CPI, with explicit thresholds for pausing, decreasing, and increasing bids
✦Crashlytics-integrated safety valve that halts bid increases when crash-free percentage drops, plus a runnable daily operating cadence for solo developers
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
ASA Campaign Management API uses a JWT signed with an Apple-issued ES256 key as a client_assertion, then exchanges it for an access token via OAuth Client Credentials. I tried this with curl first and ended up wrangling header escaping; eventually it collapsed into a small Node module.
// asa_token.mjs — invoked as a child process by the Antigravity sub-agentimport { 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) // up to 180 days .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 };
In the first version I was minting a fresh token on every call — roughly 300 redundant auth requests per day. I asked the Antigravity Agent View to redesign the caching, and it suggested an in-memory cache keyed on expires_in - 60 seconds. Adopting that cleared up an intermittent 401 I had been seeing once or twice a week.
Three small landmines that cost me half a day each, written out so you don't repeat them:
Forgetting scope: 'searchadsorg' — Apple's docs mention it almost in passing. Without it, token minting succeeds, but downstream API calls return 401 with no useful message.
Forgetting the X-AP-Context: orgId=... header — If your Apple ID manages multiple organizations, the API returns 200 with an empty result set when this header is missing. From the agent's perspective every keyword looks like zero impact, which can trigger sweeping pauses.
Trusting expires_in: 3600 literally — Empirically I see 401s starting around the 50-minute mark. Refresh on expires_in - 300 seconds and the morning runs stay clean.
I leave these as comments at the top of asa_token.mjs so that when Antigravity edits the file later, the agent reads them and respects the constraints.
Pulling the keyword report into the agent's workspace
GET /api/v5/reports/campaigns returns flexible JSON aggregations. My sub-agent fetches the last seven days of keyword-level data each morning at 06:00 JST, drops the result into the agent workspace, then runs the decision logic on top.
One small operational rule I converged on: never hand the raw JSON to the agent. I narrow it to the top 50 high-spend keywords and the bottom 20 low-spend keywords, and I pre-compute per-row metrics (CPI for the last 7 days, estimated ROAS, week-over-week install growth). Constraining the information surface keeps the agent's recommendations reproducible.
A deterministic policy for moving bids
This is the part where I tried to be clever and got burned. My first version asked an LLM to decide bids in natural language. On day three, the agent recommended a bidAmount.amount: 999.99 jump on a single keyword because it was reading a stale prompt that still assumed JPY when I had already migrated reports to USD. From then on, the rule became: decisions are deterministic code; the agent only handles anomaly detection and natural-language summarization.
The rule sheet has to fit on a single page. The minute a solo developer accumulates three nested black boxes, debugging becomes prohibitive. After the policy runs, my agent's only remaining job is to translate the BidDecision list into a Slack message I can scan in 30 seconds.
When to promote Broad into Exact Match
Search Match in ASA lets Apple choose matching queries automatically. It is convenient, but running it alongside Exact campaigns can starve your branded Exact campaigns of budget. My sub-agent pulls a separate Search Term report and looks for queries like:
When a Broad-matched query consistently converts, it gets promoted to an Exact Match keyword in a dedicated campaign. Conversely, when Search Match catches a branded query that already lives in another campaign, it is added as a negative keyword on the Search Match side to stop the cannibalization.
Most of that runs automatically, but once a month I ask Antigravity to summarize the trailing 30 days of Search Term performance and propose promotion and exclusion candidates. I approve everything by hand for that monthly pass — five minutes over coffee.
The Crashlytics safety valve
This is the most important section if you take only one thing away. When ad spend rises but the latest build is crashing, every incremental user accelerates the rating decay. I lived this with one wallpaper app: the 1-star rate spiked and the average rating dropped from 4.6 to 4.1 inside two weeks. The recovery took months.
So the ASA agent never increases bids without checking Firebase Crashlytics' crash_free_users_percentage.
# crashlytics_gate.pyCRASH_FREE_FLOOR = 99.4 # 0.2pt below the trailing 6-month mediandef is_safe_to_bid_up(crash_free_pct: float, daily_active_users: int) -> bool: if daily_active_users < 500: # DAU is too small for a meaningful crash signal — be conservative return False return crash_free_pct >= CRASH_FREE_FLOOR
If is_safe_to_bid_up returns False, every "increase" decision in that morning's batch is downgraded to "hold". Since this gate went live, I have not had a single incident where a worsening crash rate and a bid increase compounded.
A guiding principle that emerged from this work: in indie operations, make the "back off" decision smarter than the "lean in" decision. The hardest part of running solo is recognizing when to slow down, and the agent is best used to enforce that discipline.
A case of forgetting to kill a low-LTV keyword
Worth a confession. In week two, a derivative of the calming wallpaper cluster started picking up — CPI around JPY 180, 30 installs a day, very tidy on the surface. The agent dutifully nudged the bid to JPY 210.
Three days in, Firebase Analytics showed a Day7 retention of 4.8%. The median for my wallpaper apps sits around 21% on Day7, so this was less than a quarter of normal. AdMob revenue lagged accordingly and the estimated ROAS came out at 0.12. I was quietly burning about JPY 6,000 a day.
The root cause was embarrassing: my ROAS calculation was dividing by 24-hour install revenue instead of 7-day cumulative revenue. New users hit very few ads on day one, so the denominator was a fraction of what it should be. I fixed the windowing and the agent paused the cluster within a single morning run; daily spend dropped from JPY 6,000 to JPY 800 by the third day.
The recommendation I took from that experience: make sure the numerator and denominator of any ROAS calculation share the same time window, and enforce that in code rather than in the prompt. Any time the windows drift apart, force the agent to hold rather than increase. Data-layer defence beats agent-layer defence.
A complementary trick: any keyword with three consecutive days of 0% purchase-rate among first-24-hour users gets auto-paused, regardless of CPI. This catches low-LTV traffic before Day7 data even arrives. The first time I switched it on, four keywords paused themselves overnight and I recovered about JPY 18,000 of monthly spend.
Tying ASA, AdMob, StoreKit 2, and Crashlytics into one weekly view
I prefer to look at bidding alongside AdMob eCPM and StoreKit 2 subscription health on the same screen. What Antigravity does well here is letting the Background Agent View aggregate output from multiple sub-agents into a single artifact.
Concretely, AdMob comes through the Reporting API, StoreKit 2 through the App Store Server API, and Crashlytics through a thin Firebase Cloud Function proxy. Each client implements the same interface so the agent can call fetchWeeklyMetrics(source) four times.
Promise.all keeps the whole pull under four seconds. The sub-agent only sees the consolidated JSON and only writes the narrative summary on top. Keeping data acquisition in deterministic code is what keeps the AI surface area small.
My apps install across Japan, the US, Taiwan, Korea, and Brazil. ASA returns spend in local currency unless told otherwise, so I normalize everything to JPY before the agent touches it.
A separate Background Agent refreshes RATES every Monday morning from an FX provider; if that step fails, Slack gets a "FX update failed" alert because stale rates are worse than no rates when bids are at stake.
One more regional nuance: US CPI tends to be 2–3x Japan CPI in JPY terms, but ARPU is also significantly higher. Comparing US CPI to a single global target will quietly suffocate your best-performing US keywords. I keep target_cpi per region and pass region into every report row given to the agent. That single change reduced false pauses by roughly five per month in my running operation.
Heuristics I'd give myself on day one
Three weeks of operating this loop produced a handful of principles I'd send back to my past self.
Design the "stop" before the "go". Pausing rules deserve more thought than bid-up rules in indie operations.
Trim the information you hand the agent. Raw API responses produce sprawling recommendations; pre-computed top-N tables produce repeatable ones.
Whatever can be code, write as code. Reserve the LLM for anomaly detection and narrative summarization.
Keep a human checkpoint each morning. A Slack message with three buttons — approve, reject, defer — closes the loop in under three minutes a day.
Write the failures down. My _runbooks/asa.md has a paragraph titled "week 2 incident: calming wallpaper cluster" that has prevented me from repeating the same mistake twice.
A sustainable daily cadence for solo operations
The cadence I'm settling on after three weeks of refinement:
06:00 JST — Background Agent fetches the trailing 7-day ASA report and runs decide_bid for every keyword.
06:15 JST — Crashlytics is queried; is_safe_to_bid_up decides whether to downgrade "increase" actions to "hold".
06:30 JST — Slack receives the proposed actions with an agent-generated explanation per row.
07:00 JST — I review and tap approve / reject / defer; the entire review fits within three minutes.
Approved decisions only flow into a second sub-agent that calls Campaign Management API endpoints to apply changes.
First Monday of the month, 10:00 JST — Search Term promotion / negative-keyword candidates are proposed in one Slack message.
Anytime — A single button in Antigravity Agent View halts every sub-agent at once.
What I like about this cadence is that the morning shrinks to three minutes, but the human still keeps the right to stop. AdMob revenue moves up and down with seasonality, but the ASA bidding rhythm now feels calm — which is the result I was actually after.
Thank you for reading this far. I've written more about pairing Antigravity with Crashlytics in a separate piece if that part is interesting to you. If ASA is new to your stack, the gentlest place to start is one Exact Match campaign read daily by an Antigravity Background Agent. That is exactly where I began.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.