Tuning AdMob Placements at Runtime with Firebase Remote Config and an Antigravity Agent
A practical look at how I combine Firebase Remote Config with an Antigravity Agent to nudge AdMob placements while the app is live, drawn from years of running wallpaper apps as an indie developer.
Every time I nudged an ad setting, I spent the following week bracing for the numbers. On the wallpaper apps I have run the longest, that round trip was quietly eating more of my time than the actual building. Inch the App Open, interstitial, and rewarded frequencies in the AdMob dashboard. Check Day 1 retention in the Firebase Console. Check eCPM. Check IAP conversion. Some months I spent more hours watching those dials than writing features.
For the past few months I have been pulling that work apart and reassembling it as a small, safe nightly tuning loop built around Antigravity's Agent and Firebase Remote Config. The parts that work give me real confidence, and the boundaries where I have decided not to let the Agent in have become much clearer. Below is the setup itself, the auto-revert that keeps a bad call from sitting live for 40 hours, and six months of measured results — from apps that already earn real money, not a greenfield project.
The catalyst: a 2% Day 1 dip I could not explain
The trigger was last fall's release. I changed App Open frequency from "once per session" to "suppress on Day 0, unlimited from Day 1 onward." Within a week, Day 1 retention in Firebase Analytics dropped from 33% to 31%. At my scale of a few hundred thousand DAU, two points matter.
Metric
Before (7-day avg)
After (7-day avg)
Delta
Day 0 → Day 1 retention
33.4%
31.2%
-2.2pt
Interstitial eCPM
$4.12
$4.05
-1.7%
App Open eCPM
$2.85
$3.91
+37.2%
Daily ad revenue
$1,820
$1,910
+4.9%
Short-term revenue went up. The catch was that the retention dip ate into LTV, and three months later the ad revenue had reversed and was sliding. I had bundled three other changes into that release, so I could not tell which knob had moved the needle. I ended up rolling the whole app back to the previous version and rolling back the ad units in the AdMob dashboard by hand. The three days I spent waiting on App Store and Google Play review cost an estimated $5,000 in lost ad revenue.
Sitting on that loss as a single-person operation, the lesson burned in: monetization decisions need to live on a different cadence than your app binary. The moment you couple them to a release, your rollback latency is bounded by store review. That is when I started taking Firebase Remote Config seriously.
Why Remote Config alone was not enough
Remote Config itself slotted in quickly. I moved the variables I wanted to nudge — ad unit IDs, frequency caps, the Day 0 suppression flag, the rewarded multiplier — into Remote Config, and the app reads them at launch via fetchAndActivate() and passes them into the AdMob wrapper. Tweaks no longer needed a release, and rollback was just "set it back to the previous value."
What Remote Config did not solve was the harder problem: deciding which value to move next. That part stayed manual. Each morning looked like:
Look at eCPM trends in the AdMob dashboard
Look at Day 1 retention in Firebase Console
Check Google Play Console and App Store Connect for crashes and reviews
Synthesize all of the above into a judgment like "today let's pull App Open frequency from 1.0 to 0.7"
Update Remote Config
Thirty to forty-five minutes of that, every morning, quietly eats the rest of my indie dev day. Across multiple apps in parallel, weekends started to feel like an extension of ad monitoring. The question of this piece is whether Antigravity's Agent can be inserted into that loop.
✦
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
✦Splitting Remote Config keys into tunable. and fixed. so the Agent's reach is narrowed mechanically
✦A concrete nightly tuning loop, plus the auto-revert that stops a bad call sitting live for 40 hours
✦How to choose the share of users your Agent is allowed to touch without breaking eCPM or Day 1 retention
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.
The first principle I locked down: do not give the Agent full authority to decide. Remote Config ships directly to live users, so an Agent that goes off the rails can break every user's experience at once. The boundaries I chose are:
The only parameters the Agent can touch are Remote Config keys marked "dynamic-tunable"
Any value change is capped at ±15% of the current value, and must stay within a pre-defined absolute floor and ceiling
The change applies to at most 10% of users (controlled via a Remote Config condition)
The Agent may only use Firebase Analytics, AdMob, and Crashlytics metrics as inputs. Moving values based on a sentiment read of review text is forbidden
I wrote these boundaries into AGENTS.md as guardrails the Agent is required to read. The relevant section looks like this:
## Constraints on automated Remote Config tuningIn this repo, only Remote Config keys with the `tunable.` prefix are eligiblefor Agent-driven adjustment. Keys with the `fixed.` prefix must never bemodified by the Agent.- At most 2 keys may be changed in a single tuning pass- New values must stay within ±15% of the current value- Changes apply only to the "audience: opt_in_experiment" segment (10% of users)- Each key may be changed at most once per day- If the last 24h Crashlytics crash-free users metric drops below 99.0%, no automated tuning runs at all (human review required)
Only after this was written down could I hand the Agent the "look at the data and decide" role. The next step was to express the decision loop as an Antigravity Workflow.
The shape of the nightly tuning loop
The loop runs once a day at 02:00 JST and goes through six steps. I picked the dead of night so that the impact of any change is visible by the time I wake up.
Pull the last 7 days of metrics from Firebase Analytics (BigQuery export) and AdMob
Pull the Crashlytics crash-free users figure. If it is below 99.0%, exit
Pull the last 24 hours of Remote Config change history (Firebase Remote Config REST API)
Hand all of that to an Antigravity Agent and ask for at most two proposed key/value changes
Validate each proposal against the AGENTS.md guardrails. Discard anything that fails
Apply surviving proposals to Remote Config and notify Slack
The first version of this had step 4 flow directly into step 6. A few clearly bad proposals slipped through, so I broke out step 5 as an independent validation step. Having a deterministic, hand-written validator sit between the Agent's proposal and production has held up well.
I do not let the Agent write this validator. The judgment can be delegated, but the validation of that judgment runs through deterministic code I wrote by hand. Once I split those roles, my comfort level running this against production went up another notch.
A gotcha around metric freshness
Anyone running the BigQuery export for Firebase Analytics knows the default: previous-day data lands a few hours into the morning. If your tuning loop runs at 02:00, what it actually has access to is the day before yesterday's data. If you don't recognize this, the Agent will propose changes based on a world that doesn't reflect what you just did.
I always prepend the Agent's context with a freshness note:
Data freshness notes:- Firebase Analytics: last 24h not included (BigQuery export spec)- AdMob API: today's figures finalize in the afternoon, so the last 12h are excluded- Crashlytics: roughly real-time (about 1 hour delay)So at 02:00 today, the most recent Analytics data available is from 5/19.The effect of any Remote Config change you made yesterday (5/20) cannot yet bejudged. If a change exists from yesterday, mark it as "impact unmeasured" andhold off on new proposals.
Since adding this note, the Agent has stopped its most dangerous behavior — changing a value immediately after a previous change. Separating "what to prevent with prompts" from "what to prevent with code" is a discipline you have to develop deliberately when you put Agents in production.
Wrapping it in an Antigravity Workflow
Antigravity's Workflow feature ties the whole loop together in a single file. Mine lives at .antigravity/workflows/admob-nightly.yaml:
agent.profile references an Agent profile registered on the Antigravity side. The profile definition constrains the tools the Agent may use, the AGENTS.md sections it may read, and the output format it has to produce.
Forcing output_schema means I always get a structured JSON alongside the Agent's natural-language reasoning. That JSON is what flows into the validator. If you let an Agent emit free-form text downstream, format drift is guaranteed somewhere. The biggest lesson from running this for six months is to enforce a structured handoff at every Workflow boundary.
How to choose the share of users you tune
Setup talk aside, the hardest operational question is: how large a slice of users do you let the Agent touch? My current setting:
90% of the audience runs on hand-set values (the "fixed" bucket)
The remaining 10% is eligible for Agent-driven tuning (the "experiment" bucket)
Inside the experiment bucket, a Remote Config condition further splits it into two sub-buckets: one keeps the 7-day baseline, the other applies Agent proposals
I started at 30% for the experiment bucket and stepped it down. At 30%, when the Agent moved a couple of values in close succession, overall eCPM would swing 5-8%. At 10%, the swing stays under 1% and the worst-case blast radius is something I can reason about.
For an indie developer running on AdMob, revenue predictability is everything. Heavy month-to-month eCPM volatility makes monthly set-asides and forward planning harder. My north star is "monthly revenue volatility under 5%," and the experiment bucket size is chosen to stay under that ceiling.
The judgment layer behind the tuning
The playbook (playbook.md) handed to the Agent spells out the order of judgment in prose:
## Priority of judgment1. If crash-free users is below 99.0%, propose nothing at all2. If Day 1 retention is more than 1.5pt below the 7-day average, only propose changes in the direction of reducing ad exposure (negative adjustments)3. If Day 1 retention is stable (within +/-0.5pt of the 7-day average) AND eCPM has fallen below its 14-day low, only propose format-switching keys4. If none of the above apply, return "no proposal"## Forbidden proposals- Setting the rewarded multiplier above 1.5x- Setting the App Open frequency above 1.5 per session- Flipping the Day 0 suppression flag to false (Day 0 is sacred for retention)
Making the order of judgment explicit stabilizes the Agent's proposals. One subtle distinction: keep the judgment in the playbook, not in AGENTS.md. AGENTS.md is for "guardrails I never want to relax"; playbook.md is for "this week's strategy" and is expected to change. Separating these two roles makes the improvement cycle much easier to run.
Slack notification: separating judgment from intent
A final design note about Slack. Every proposal the Agent makes, applied or not, lands in Slack so I can scan it in the first five minutes of my morning. The body looks like this:
[AdMob nightly tuning] 2026-05-21 02:14 JSTMetrics evaluated (5/13-5/19)- Day1 retention: 32.8% (-0.3pt vs 7d avg)- Interstitial eCPM: $4.07 (-2.4% vs 14d min)- App Open eCPM: $3.21 (-5.6% vs 14d min)- Crash-free users: 99.62%Agent proposals (2)1. tunable.interstitial_frequency_cap: 4 -> 3 (-25.0%) reason: eCPM fell below 14d low; reduce frequency to recover unit price -> rejected: delta 25.0% exceeds 15% cap2. tunable.app_open_min_interval_sec: 30 -> 35 (+16.7%) reason: App Open eCPM trending down; widen interval to preserve unit price -> rejected: delta 16.7% exceeds 15% capChanges applied: 0Note: both proposals were rejected by the guardrail this run.If you see three consecutive days of all-rejections, revisit the playbook.
When that lands, I decide whether to nudge Remote Config by hand or whether to loosen the playbook. Every Agent proposal — including the rejections — is logged in Slack, so later I can go back and understand exactly why the Agent did or did not act. Making the boundary between Agent decisions and human decisions auditable is, for me, the minimum bar for trusting an Agent in production.
Not leaving a bad call live for 40 hours
About two months into running this, I found the hole in my own design. A value applied at 02:00 does not show up as a full day of Analytics data until the BigQuery export settles the next morning, around 09:00. And the loop itself cannot evaluate that day until the following night — roughly 48 hours after the change went out.
So a proposal that clears every guardrail but is quietly wrong sits on 10% of my users for two full nights. The crash-free gate only runs once, at the top of the loop, so any degradation that does not crash the app walks straight past it. That is not hypothetical: a proposal that widened the App Open minimum interval shipped, and I only noticed the shortened sessions in the experiment bucket two days later.
The fix was a second, faster signal path that can revert without waiting for me.
Picking a proxy metric
Day 1 retention is simply not readable in time. What I use instead is the share of sessions abandoned within three seconds of an ad impression — I call it the rage-quit rate. Dropping the app immediately after being shown an ad turned out to lead retention damage reliably. Backfilling six months of data, days where that rate moved by +1.5pt or more were followed by a Day 1 retention drop averaging 0.9pt.
I do not route it through Analytics, because that puts me back behind the export. A 15-minute batch increments rolling counters in Firestore instead.
Signal
Time to readable
Usable for auto-revert?
Day 1 retention (BigQuery)
~31 hours
No
eCPM (AdMob API)
~12 hours
No
Crash-free users (Crashlytics)
~1 hour
Crashes only
Rage-quit rate (Firestore counters)
~8 minutes
Yes
Decide it is not noise before you revert
Fast signals carry their own hazard. A 15-minute window holds few samples, and a naive "revert if it moves +1.5pt" rule flaps constantly. My first version did exactly that and rolled back four times in one day for no reason at all.
Now it computes a 95% confidence interval on the difference between the two bucket proportions and only fires when the lower bound clears zero.
// functions/src/rollback/significance.ts/** 97.5th percentile of the standard normal (two-sided 95%) */const Z_95 = 1.959964;export type BucketCount = { events: number; quits: number };/** * Is the experiment bucket's rage-quit rate meaningfully worse than the * fixed bucket's — i.e. is the lower bound of the difference above zero? */export function isSignificantlyWorse( experiment: BucketCount, fixed: BucketCount, minSamplePerBucket = 800,): { fire: boolean; diffPt: number; ciLowPt: number; reason: string } { if (experiment.events < minSamplePerBucket || fixed.events < minSamplePerBucket) { return { fire: false, diffPt: 0, ciLowPt: 0, reason: "sample too small" }; } const pE = experiment.quits / experiment.events; const pF = fixed.quits / fixed.events; const diff = pE - pF; // Unpooled standard error: we only care about detecting one-sided harm const se = Math.sqrt( (pE * (1 - pE)) / experiment.events + (pF * (1 - pF)) / fixed.events, ); const ciLow = diff - Z_95 * se; return { fire: ciLow > 0, diffPt: diff * 100, ciLowPt: ciLow * 100, reason: ciLow > 0 ? "experiment significantly worse" : "within noise", };}
On top of that, the revert requires fire === true in two consecutive windows. A single significant window can be an artifact of the user mix shifting suddenly — a Play Store feature placement, for instance. That exact false positive happened once in six months.
Running the same test against my own headline number is sobering, and worth doing. The +3.2% eCPM advantage the experiment bucket shows over the fixed bucket comes with a 95% confidence interval of −0.4% to +6.9% when computed on six months of per-user daily revenue. It leans positive, but it straddles zero. The numbers that flatter your own system are the ones most worth putting through the test.
Revert one key, not the whole template
This is where the implementation surprised me. Remote Config exposes projects/{project}/remoteConfig:rollback, which takes a versionNumber and restores it. Convenient — but it restores the entire template. Any manual edit I made to a different key after the Agent's change gets wiped along with it.
So the normal path fetches the template, rewrites only the target key, and uses the ETag as an If-Match optimistic lock. The whole-template :rollback is reserved as a last resort, after three consecutive ETag conflicts on the targeted path.
// functions/src/rollback/revert-key.tsimport { google } from "googleapis";const RC_BASE = "https://firebaseremoteconfig.googleapis.com/v1";export async function revertKey( projectId: string, key: string, previousValue: string,): Promise<{ reverted: boolean; note: string }> { const auth = new google.auth.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/firebase.remoteconfig"], }); const client = await auth.getClient(); const token = (await client.getAccessToken()).token; // 1. Pull the live template along with its ETag const getRes = await fetch(`${RC_BASE}/projects/${projectId}/remoteConfig`, { headers: { Authorization: `Bearer ${token}` }, }); const etag = getRes.headers.get("etag"); const template = await getRes.json(); if (!etag) { return { reverted: false, note: "etag missing; abort" }; } if (!template.parameters?.[key]) { return { reverted: false, note: `key disappeared: ${key}` }; } // 2. Touch only the target key template.parameters[key].defaultValue = { value: previousValue }; // 3. Optimistic lock. A concurrent edit comes back as 409 const putRes = await fetch(`${RC_BASE}/projects/${projectId}/remoteConfig`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; UTF-8", "If-Match": etag, }, body: JSON.stringify(template), }); if (putRes.status === 409) { return { reverted: false, note: "etag conflict; caller should retry" }; } if (!putRes.ok) { return { reverted: false, note: `unexpected status ${putRes.status}` }; } return { reverted: true, note: `reverted ${key} -> ${previousValue}` };}
The previousValue it writes back is persisted to Firestore by apply.ts in the same transaction that applies the change. Decide where you are reverting to before you move forward — otherwise, at 02:00, there is nowhere to go back to.
Three firings, and what they cost
The auto-revert has triggered three times: two true positives, one false. The interesting true positive was the widened App Open interval — the longer gap made the full-screen ad feel like an unexpected intrusion rather than a familiar one, and abandonment went up. Mean time from apply to revert was 41 minutes. Compared with me noticing on the morning of the second day, that is roughly a seventieth of the exposure.
The limits are worth stating plainly. This catches coarse damage that shows up immediately after an impression. The slower kind — an app that becomes a little more tiring to open each week — does not register in a proxy metric at all. Fast signals exist to stop accidents. Deciding whether a change is actually good still belongs to the slow ones.
Six months in: what worked and what didn't
After six months in production, the wins are clear and the limits are clearer.
Wins:
Time spent on daily ad monitoring dropped from 30-45 minutes/day to 5-10 minutes/day
Zero ad-related incidents (no Day 1 retention drops >1.5pt) in the six-month window
The experiment bucket eCPM has averaged +3.2% over the fixed bucket (though the 95% CI runs from −0.4% to +6.9%)
The auto-revert fired three times in six months (two true positives, one false), mean recovery 41 minutes
The honest part: the fantasy of "let the Agent loose and watch eCPM soar" has not materialized. The +3.2% is largely because the fixed bucket is run conservatively. The Agent's standalone contribution is inside the noise. Today the Agent's real job is less "make smarter decisions than me" and more "absorb the daily monitoring burden so revenue volatility stays bounded."
Even so, the half-hour I get back every day is going somewhere real. A monetization stack that grows quietly but never breaks while you look away outlasts one that spikes. Six months of running this has only sharpened that view, and the Agent-driven Remote Config loop is, so far, pulling in that direction.
What I want to try next
Two improvements are queued up.
First, slice the experiment bucket by in-app behavior segments rather than by random 10%. Firebase Analytics audiences let me address "users who watched rewarded at least 3 times in the last 30 days" or "non-IAP users who lasted past Day 7" as separate cohorts, with separate tuning strategies. I already have five audiences defined and plan to wire up matching Agent profiles in the coming month.
Second, use Antigravity Background Agents to have a second Agent review the first Agent's decision log. Weekly: "of the 7 proposals last week, were the 5 rejections appropriate? Were the 2 applied changes still the right call in hindsight?" The conclusions feed back into playbook.md revisions. Continuously evaluating the quality of Agent judgment using another Agent and historical data is, I suspect, going to be a baseline requirement for any indie developer who wants to put Agents into production.
I will be publishing the full code and playbook examples separately on dolice.design. If you are operating multiple apps and tuning AdMob and Remote Config on your own, I hope some piece of this is useful. Thanks for reading.
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.