Running iOS Push Notification A/B Tests Weekly With Antigravity Agent — A Self-Improving Loop For Copy, Timing, And Segments
Drawing on 12 years of indie iOS app development across wallpaper and wellness apps with over 50 million cumulative downloads, this article walks through a weekly Push Notification A/B testing loop powered by Antigravity Agent. It covers the FCM bridge, BigQuery measurement, segment design, and a real D7 retention recovery story.
import { TerminalBox } from "@/components/article/TerminalBox";
I have been shipping iOS apps as an indie developer since 2014, mostly wallpaper, wellness, and manifestation apps that have accumulated over 50 million downloads. In that time no feature has moved the numbers as quickly — or punished mistakes as harshly — as Push Notifications. Push has the power to lift a Tuesday by twenty percent and, if you push one notification too many at the wrong hour, to wipe out two weeks of D7 retention gains. Running A/B tests by hand across five apps was eating about four hours of my week, so over the last few months I have been moving the copy generation and weekly review onto Antigravity Agent. This article documents the architecture, the failures I walked into, and the weekly routine that finally settled.
Push notification A/B testing is an old topic, but the moment you let an agent loop on it the practice changes meaningfully. The agent can suggest hypotheses no human would surface, but it has no sense of distribution discipline. If you do not build the guardrails in the right layer it will optimize for short-term open rate and quietly burn long-term retention. Everything below is shaped by that tension.
Why I Started Letting An Agent Handle The Weekly Push Loop
Running multiple wallpaper apps in parallel, push-related work kept growing. Each app needed a fresh copy hypothesis, a send time, and a careful post-send review of open rate and D7 retention. With one app this is fine. With five it is four hours every Monday.
I also do not consider wellness copy a personal strength. A small change in the ending particle of a sentence can swing open rate by something like 1.4x, and brainstorming a fresh angle every week wore me down. Once I gave Antigravity twelve weeks of A/B results and asked it to suggest the next five candidates, it began surfacing angles I would not have written. That is what convinced me to lean in.
There are two traps in delegating. First, the agent has no sense of how much volume is reasonable, so without a hard cap it tends to push more often. Second, if you let it optimize only against open rate, the punchy clickbait copy wins every time and your audience erodes quietly. I will spend the rest of the article on how I structure around both.
What Changed In My Hours
Before the loop, push-related work cost me about four hours a week. After, it is closer to thirty minutes: fifteen reviewing copy candidates, ten reading the weekly Slack digest, and five double-checking the scheduled queue. The recovered hours go into producing new wallpaper art and tuning my AdMob mediation. As an indie developer time is the single most expensive line item, so this delta matters.
The Three Layers — Copy Generation, Delivery, And Measurement
I keep three layers separate so the agent's responsibility is clear:
Copy generation — Antigravity proposes five to ten weekly copy candidates by reading the last twelve weeks of A/B results and an app-specific worldview note.
Delivery — A thin Cloudflare Workers bridge over the FCM HTTP v1 API, exposed to Antigravity as an MCP, takes only {segment, title, body, scheduled_at, ab_arm}.
Measurement — Firebase Analytics streams into BigQuery and the agent runs read-only SQL during the weekly review.
Splitting these is deliberate. If the agent can dispatch sends directly, one bad call could blast every user simultaneously. Instead delivery always goes through propose → human approve → schedule → cron-execute.
Architecture Sketch
The iOS clients store FCM tokens in Firestore. The Workers worker has both a Cron Trigger and a webhook that Antigravity can invoke. Copy candidates are committed as YAML in a GitHub PR; I review and merge; the worker pulls the latest YAML during its scheduled run. Routing copy through GitHub means change history is preserved and a runaway agent can be physically halted by closing PRs.
✦
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 architecture connecting FCM HTTP v1 API with Antigravity Agent so the loop — copy generation, scheduling, measurement, next-week recommendations — runs without daily babysitting
✦Segment design grounded in real data from wallpaper and wellness apps, plus a recovery story where over-pushing dropped D7 retention by 4.8 points and how I clawed it back
✦BigQuery SQL templates and prompt patterns for weekly Antigravity reviews that compressed 4 hours of weekly work into 30 minutes
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 FCM HTTP v1 API needs an OAuth2 access token via a service account, which is a bit fiddly to invoke from the agent directly. I push that complexity into a Cloudflare Worker.
Before, my single-developer script kept the service account key in a local env file and called FCM straight from a script:
Three problems with this. The key is tied to one machine. There is no retry path or rate control. And nothing stops an agent from blasting everyone if it later learns this endpoint exists.
After, the bridge lives in a Worker and exposes only a "schedule" verb to the agent:
The two guards are the heart of the design. Even if Antigravity decides "push more," the worker physically refuses to let it. Lead time below thirty minutes is rejected so an accidental immediate dispatch cannot happen.
Keep The MCP Surface Small
The MCP that Antigravity calls accepts only six fields. I deliberately do not expose user lists, token lookups, or worker internals. The agent is allowed to write copy and queue a send; everything else lives behind the worker.
Prompting Antigravity For Copy
Copy generation prompts live in YAML files per app. The agent reads them at the start of each run. Three things matter:
First, an explicit worldview line. For the wallpaper apps it reads "quiet, never pushy"; for the wellness apps it reads "gentle, never declarative, matches the reader's pace." Skip this and the agent picks up trending social media voice and drifts toward clickbait.
Second, include both winning and losing copy from the last twelve weeks. Showing only winners narrows the search; showing five winners and five losers and asking for "five candidates outside both buckets" produces the most interesting angles.
Third, declare the success metric and the banned phrases explicitly. The primary metric is "total active seconds within sixty minutes of open," not open rate. Banned phrases include "now," "limited," and "last chance," because optimizing for them works short-term and erodes trust.
# prompts/wallpaper-app/push-generation.yamlapp_world: tone: "quiet, never pushy" brand_voice: "evoke the urge to softly change a wallpaper at night" avoid: - "urgency words like now / limited / last chance" - "exaggerated qualifiers like god-tier / amazing" - "emoji overuse"evaluation: primary: "sum of active_time_sec within 60 minutes of tap" secondary: "D7 retention" tertiary: "AdMob impressions x eCPM" hard_caps: - "max one push per user per day" - "no sends between 23:00-08:00 local"ab_test: arms_per_week: 4 min_sample_per_arm: 3000
The agent reads this and the previous twelve weeks of champions_losers.json, then proposes four arms. Output lands in a GitHub PR, I review, merge, and the worker picks up the new YAML on its next cron tick. The PR trail means I can later go back and ask "why was that week's copy strong?"
A Small Prompting Habit That Helps
One prompt shape I now lean on: "for the cohort where D7 is down 2 points or more year over year, give me three short lines that read fine even if the user does not tap." Naming the cohort and giving permission to not be tapped keeps the agent from drifting toward open-rate maximization.
Segment Design And Timing
Do not over-segment. After several attempts I settled on five buckets: D0-D7, D8-D30, D31-D90, D91+, and paying users. Cutting finer leaves single arms with too few users to draw a conclusion.
Timing is local. About 70% of my users are in Japan, but 25% are English-speaking and the rest scattered. Sending in UTC means American users get pinged at 3 AM. The worker picks each user's local hour before dispatching:
-- Cron picks up the queue and joins users on segment + local timeSELECT u.fcm_token, s.title, s.body, s.ab_armFROM scheduled_pushes sJOIN users u ON u.segment = s.segmentWHERE s.status = 'pending' AND s.scheduled_at <= datetime('now', '+5 minutes') AND ( CAST(strftime('%H', datetime('now', u.tz_offset || ' hours')) AS INTEGER) BETWEEN 19 AND 22 )LIMIT 5000;
The single most surprising lesson: for wellness apps the strongest window was consistently around 21:30 local, presumably the pre-sleep moment. For wallpaper apps it sat at 18:30-19:30, the post-commute slice. App worldview and send time correlate strongly, so when the agent suggests a late-night window for a wellness app I reject it on instinct.
Measuring Performance In BigQuery
Stream Analytics into BigQuery and you can answer the arm-by-arm questions via SQL. The agent has read-only BigQuery access and a weekly cron runs roughly this query:
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_rateFROM push_events pLEFT 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 * 1000000GROUP BY p.ab_arm, p.segmentORDER BY open_rate DESC;
SAFE_DIVIDE matters; you do not want the table to fall over when an arm got zero opens. More importantly, I always report open_rate alongside post_open_60m_active_seconds. Open rate alone is a trap.
Minimum Sample Discipline
Three thousand sends per arm before declaring a winner. Below that, a friendly segment can carry a noisy result. I encode this rule in the YAML so the agent does not over-claim.
A Push Outage Of My Own Making — Losing 4.8 Points Of D7
The first month I ran this loop I made a clean mistake. The agent's copy was winning on open rate, so I raised the cap from "one per user per day" to "two." Short-term open rate climbed. Three weeks later D7 retention was down 4.8 points across the app.
Cause: the second daily push slipped into late-evening windows and a chunk of users disabled notifications outright in iOS settings. AdMob impressions fell about 12% and roughly thirty thousand JPY of monthly revenue evaporated.
Recovery looked like this:
Reset MAX_PUSH_PER_DAY to 1 in the worker and ship within the hour.
Pause sends to anyone who had received two pushes in the previous fourteen days for one full week.
Show a quiet in-app banner — never a popup — explaining how to re-enable notifications. No urgency language.
Rewrite the Antigravity prompt to state explicitly: "the hard cap exists to protect D7 retention; never propose raising it."
After that I made two structural changes I now treat as load-bearing: short-term and medium-term KPIs always travel together to the agent, and hard caps live in the worker, not in the prompt. The agent is good at short-term optimization. It is the human's job to install the structures that surface medium-term decay.
The Weekly Cadence — What The Agent Does And What I Do
Monday morning: Antigravity ships a Slack digest summarizing last week's arms, declaring winners and losers, and proposing five candidates for the coming week. This is fully scripted via cron.
I spend roughly thirty minutes reviewing. I check whether the winners respect the worldview, whether D7 is holding, and whether AdMob is moving in the expected direction.
I narrow the five candidates to four, occasionally rewriting one by hand, then open a GitHub PR. Merging it lets the worker enqueue next week's sends in sequence.
End of month: I ask Antigravity for "trends across the past four weeks and segment ideas worth trying next month." This is scaffolding for my own thinking; I always make the final call.
Three rules are non-negotiable: dispatch, KPI definition, and hard caps stay with the human. The agent owns copy candidates and hypothesis generation. The moment that boundary blurs, the cap-relaxation story above repeats.
Areas I Have Not Touched Yet, And What I Want To Try Next
A few things on my own list, mostly as a way of closing this article rather than padding it.
First, per-user recommendation pushes. Today I send by segment; Firestore already has each user's recent wallpaper history, so I could ask Antigravity for short copy that hints at a new release in a category the user has opened five-plus times in thirty days. Privacy framing matters — whether to say "based on what you have been viewing" or not is a design question, not a technical one.
Second, silent push for pre-load. The idea is to pre-fetch the next wallpaper to the device a few minutes before the visible push fires, so the opening tap feels instant. The agent's job here shifts from copy to batch planning.
Third, AdMob-aware send volume. Weekend eCPM tends to dip, so concentrating sends on weekday evenings might smooth monthly revenue. I expect to run this experiment this quarter.
I hope this is useful if you are sketching an agent-assisted push loop of your own. Push is a small surface where worldview must fit into one line, and that constraint is why I think agents shine here in tandem with a careful human reviewer. 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.