●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Generating Multilingual Onboarding Copy with Gemini 2.5 Flash and Rolling It Out via Firebase Remote Config: An Indie Developer's Notes
An indie developer's implementation notes on generating multilingual onboarding copy for six wallpaper apps with Gemini 2.5 Flash and validating it through Firebase Remote Config gradual rollout, including Apps Script code, D1 retention measurement, and AdMob eCPM separation.
Firebase Remote Config rolls out a new value, and D1 retention shifts by 0.8 points. Do you dismiss that as noise, or do you treat it as a real signal from the new copy? When you're an indie developer running six wallpaper apps in parallel, whether you have the measurement plumbing to answer that question changes the precision of every subsequent decision.
Recently, working solo, I built a pipeline that generates onboarding copy for six apps with Gemini 2.5 Flash and ships it through Firebase Remote Config with a gradual rollout. What used to take several days of back-and-forth with a translation service now runs in tens of seconds from an Apps Script trigger.
This article is a write-up of the actual Apps Script code I run in production, the Analytics event design I use to measure D1 retention per variant, and the numbers I've started seeing across six apps. Everything was verified in May 2026 against Gemini 2.5 Flash and the Firebase Remote Config REST API v1.
Why move onboarding copy into Remote Config at all
If you hard-code onboarding copy into your app resources (strings.xml on Android, Localizable.strings on iOS), every word change ships as a new binary that has to go through store review. That's one to three days of latency, and once a version is out you can't easily roll back if a new phrasing turns out worse. I couldn't tolerate that lag, so the copy for the first three onboarding screens now lives entirely in Remote Config.
When deciding what goes into Remote Config, I use three criteria.
Does the copy need to be swappable without store review?
Does it need multilingual variants? (For me that's English, Japanese, Simplified Chinese, Spanish, Portuguese, German, French — seven languages.)
Do I want to A/B test variations of it?
Anything that touches legal language — terms of service, privacy policy excerpts, push notification permission explanations — stays in the resource files. Remote Config-driven copy gets regenerated regularly, and the nuance shifts slightly each time. Putting legally reviewed copy on that path is asking for trouble. Drawing that line clearly up front made everything downstream easier to operate.
Why I picked Gemini 2.5 Flash
I compared Gemini 2.5 Flash, Gemini 2.5 Pro, and Gemini 3 Pro before settling on Flash. My reasoning came down to three points.
Cost. Generating 7 languages × 3 screens × 6 apps = 126 strings weekly costs roughly ¥2,000–¥3,000 per month on Pro tier. On Flash, the same workload comes in under ¥300 per month. Across a year of operation that's a massive difference.
Latency. Average generation time per string is around 800ms on Flash, versus 2.5–4 seconds on Pro. Apps Script has a six-minute execution limit. Pushing 126 strings through Pro in one run was a non-starter.
Tone consistency. For copy that needs a soft, gentle voice matching the wallpaper apps' aesthetic, Flash produced more uniform output. Pro's longer reasoning chain occasionally produced copy that felt overly explanatory.
I wrote in an earlier article about using Pro for crash analysis and Flash for copy generation — the same split applies here. Onboarding copy benefits from brevity and consistency, not deep reasoning.
✦
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
✦Get production-ready Apps Script code that calls Gemini 2.5 Flash and writes multilingual values into Firebase Remote Config — drop it into your own project
✦Learn the exact Analytics event design for measuring D1 and D7 retention across rollout variants, and how to isolate AdMob eCPM effects per traffic source
✦See the four real pitfalls I hit while running this pipeline across six apps in parallel, and the workarounds I settled on
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.
Here's the shape of what I run. An Apps Script trigger fires weekly and does the following.
Reads the master prompt sheet in Google Sheets (per-app tone definitions, target screens, current copy)
Calls Gemini 2.5 Flash for each screen-language combination
Validates the returned JSON against length constraints (title ≤ 40 chars, body ≤ 120 chars)
Calls the Firebase Remote Config REST API to set up the gradual rollout parameters
Posts the diff to Slack as the "rollout candidate for this week"
After I approve, the script promotes the template from staging to the production rollout condition
The key design choice is that AI output never reaches users without me looking at it first. Slack diffs go through a human review step every time. If anything looks off, I reject and regenerate. From a Helpful Content perspective, keeping the final judgment in human hands is a deliberate safeguard.
Calling Gemini 2.5 Flash from Apps Script
Here's the actual Apps Script function I use. It's just a UrlFetchApp call to the REST endpoint, so the same logic works on Cloud Functions or any other backend.
function generateOnboardingCopy(appId, screenId, lang, tone, currentCopy) { const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY'); const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; const systemInstruction = [ `You write onboarding copy for the indie wallpaper app "${appId}".`, `Tone: ${tone}`, `Language: ${lang}`, `Screen: ${screenId}`, `Title must be 40 characters or fewer. Body must be 120 characters or fewer.`, `Do not use emoji. Avoid hype words ("best", "ultimate", "amazing", etc.).`, `Always return JSON: {"title": "...", "body": "..."}` ].join('\n'); const userPrompt = [ `Current copy:`, `Title: ${currentCopy.title}`, `Body: ${currentCopy.body}`, ``, `Rewrite this for ${lang}. Preserve the meaning but make it warmer and more natural for native readers.` ].join('\n'); const payload = { systemInstruction: { parts: [{ text: systemInstruction }] }, contents: [{ role: 'user', parts: [{ text: userPrompt }] }], generationConfig: { temperature: 0.7, maxOutputTokens: 512, responseMimeType: 'application/json', responseSchema: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] } } }; const response = UrlFetchApp.fetch(endpoint, { method: 'post', contentType: 'application/json', payload: JSON.stringify(payload), muteHttpExceptions: true }); if (response.getResponseCode() !== 200) { throw new Error(`Gemini API error ${response.getResponseCode()}: ${response.getContentText()}`); } const data = JSON.parse(response.getContentText()); const text = data.candidates[0].content.parts[0].text; const parsed = JSON.parse(text); // Length validation after parsing if (parsed.title.length > 40 || parsed.body.length > 120) { throw new Error(`Length violation: title=${parsed.title.length}, body=${parsed.body.length}`); } return parsed;}
The responseSchema field is the important part. Gemini 2.5 Flash will usually return valid JSON when you set responseMimeType: 'application/json' alone, but combining it with responseSchema raises reliability noticeably. The rate at which I see schema violations dropped from 0.3% to 0.02% in my logs after adding the schema.
Writing values into Remote Config via REST
The Remote Config write is more involved on the auth side. Instead of pulling in the Firebase Admin SDK, I construct a service account JWT inside Apps Script and exchange it for a Bearer token. This is what the actual update function looks like.
function updateRemoteConfig(appId, copyMap) { const accessToken = getServiceAccountAccessToken_(); const projectId = PropertiesService.getScriptProperties().getProperty(`FIREBASE_PROJECT_${appId}`); // 1. Fetch the current template with ETag const getResp = UrlFetchApp.fetch( `https://firebaseremoteconfig.googleapis.com/v1/projects/${projectId}/remoteConfig`, { method: 'get', headers: { Authorization: `Bearer ${accessToken}` }, muteHttpExceptions: true } ); if (getResp.getResponseCode() !== 200) { throw new Error(`Remote Config get failed: ${getResp.getContentText()}`); } const etag = getResp.getHeaders()['ETag']; const template = JSON.parse(getResp.getContentText()); // 2. Update parameters in multi-locale form for (const [screenId, langMap] of Object.entries(copyMap)) { for (const [lang, copy] of Object.entries(langMap)) { const key = `onboarding_${screenId}_${lang}_title`; template.parameters[key] = { defaultValue: { value: copy.title }, valueType: 'STRING', description: `Auto-generated by Gemini 2.5 Flash on ${new Date().toISOString()}` }; const bodyKey = `onboarding_${screenId}_${lang}_body`; template.parameters[bodyKey] = { defaultValue: { value: copy.body }, valueType: 'STRING', description: `Auto-generated by Gemini 2.5 Flash on ${new Date().toISOString()}` }; } } // 3. Build the 20% gradual rollout condition template.conditions = template.conditions || []; const rolloutCondName = `onboarding_v3_rollout_20`; if (!template.conditions.find(c => c.name === rolloutCondName)) { template.conditions.unshift({ name: rolloutCondName, expression: `percent <= 20`, tagColor: 'BLUE' }); } // 4. PUT it back with If-Match const putResp = UrlFetchApp.fetch( `https://firebaseremoteconfig.googleapis.com/v1/projects/${projectId}/remoteConfig`, { method: 'put', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json; UTF-8', 'If-Match': etag }, payload: JSON.stringify(template), muteHttpExceptions: true } ); if (putResp.getResponseCode() !== 200) { throw new Error(`Remote Config put failed: ${putResp.getContentText()}`); } return JSON.parse(putResp.getContentText());}
ETag handling is not optional here. If you send a PUT without If-Match, you risk overwriting a concurrent update without warning. I nearly did exactly this once and spent a late evening manually reconstructing the Remote Config for five apps. Since then the ETag round-trip has been mandatory in every code path that writes Remote Config.
Measuring D1 retention per rollout variant
Remote Config's gradual rollout splits users randomly by percentage. In my setup, 20% of users see the new copy and 80% stay on the previous version. The crucial piece is making sure Firebase Analytics can distinguish which variant each user saw. I do this by writing the Remote Config value back into a user property as soon as the fetch completes.
// Android: Kotlinval remoteConfig = FirebaseRemoteConfig.getInstance()remoteConfig.fetchAndActivate().addOnCompleteListener { task -> if (task.isSuccessful) { val variant = remoteConfig.getString("onboarding_variant") // "v3" or "v2" FirebaseAnalytics.getInstance(this).setUserProperty("onboarding_variant", variant) FirebaseAnalytics.getInstance(this).logEvent("onboarding_shown") { param("variant", variant) param("screen", "welcome") } }}
Once onboarding_variant is on the user property, you can filter your BigQuery export with WHERE user_properties.onboarding_variant = 'v3'. Without this property, you can't statistically validate whether a D1 retention shift is real. With it, the SQL becomes straightforward.
WITH first_open AS ( SELECT user_pseudo_id, MIN(event_date) AS first_day, ANY_VALUE(user_properties) AS uprops FROM `myproject.analytics_123456789.events_*` WHERE event_name = 'first_open' AND _TABLE_SUFFIX BETWEEN '20260510' AND '20260518' GROUP BY user_pseudo_id),day1_active AS ( SELECT DISTINCT user_pseudo_id, event_date FROM `myproject.analytics_123456789.events_*` WHERE event_name IN ('app_open', 'screen_view'))SELECT (SELECT value.string_value FROM UNNEST(fo.uprops) WHERE key = 'onboarding_variant') AS variant, COUNT(DISTINCT fo.user_pseudo_id) AS new_users, COUNT(DISTINCT CASE WHEN d1.event_date = FORMAT_DATE('%Y%m%d', DATE_ADD(PARSE_DATE('%Y%m%d', fo.first_day), INTERVAL 1 DAY)) THEN fo.user_pseudo_id END) AS day1_retained, SAFE_DIVIDE( COUNT(DISTINCT CASE WHEN d1.event_date = FORMAT_DATE('%Y%m%d', DATE_ADD(PARSE_DATE('%Y%m%d', fo.first_day), INTERVAL 1 DAY)) THEN fo.user_pseudo_id END), COUNT(DISTINCT fo.user_pseudo_id) ) AS d1_retentionFROM first_open foLEFT JOIN day1_active d1 ON fo.user_pseudo_id = d1.user_pseudo_idGROUP BY variantORDER BY variant;
I run this query weekly from Apps Script and write the result back to a Sheets dashboard. After a few weeks of data, the correlation between copy changes and retention movement starts to take a recognizable shape.
Separating the AdMob eCPM signal from the retention signal
Changing onboarding copy ripples downstream. Higher retention generally means more sessions per user, more ad impressions, and sometimes a higher eCPM as users with stronger engagement intent become more profitable. So when you ask "did this new copy hurt eCPM?", you can't just compare overall eCPM before and after. You need to look at the source × variant pivot.
For my wallpaper apps, around 70% of traffic comes from organic search. The rest is split between Apple Search Ads (ASA) and Google Ads. I track D1 retention and eCPM separately for each.
A concrete result from May 2026: the v3 copy lifted D1 by 1.8 points and eCPM by 3.2% for ASA-origin users, but stayed flat for organic-search users. The current setup serves v3 to ASA traffic and keeps v2 for organic. Remote Config conditions handle the gating — combine audience with onboarding_variant and you get this kind of split routing without writing any client code.
Four pitfalls I hit while running this across six apps
The pipeline is in production now, but getting it stable took a few rounds of debugging. Here are the four places I tripped, so you can skip them.
Pitfall 1: Remote Config fetch timing
Calling fetchAndActivate() in onCreate doesn't complete in time for the very first launch. You either need a splash screen that waits a second or two, or you have to design around the fact that the first session uses the default values. I set setMinimumFetchIntervalInSeconds(0) for debug builds and 3600 seconds for release builds, but the activation window remains a thing to plan around.
Pitfall 2: Language code mismatches
iOS and Android disagree on locale strings. iOS uses zh-Hans, Android uses zh-CN. Rather than picking one and writing translation logic, I write both keys to Remote Config — onboarding_welcome_zh_title and onboarding_welcome_zh-Hans_title — so the client code finds a match regardless of which form it reports.
Pitfall 3: ETag conflicts
When you parallelize updates across apps, the gap between the GET that fetches the ETag and the PUT that writes back is enough for another script to slip in. The result is a HTTP 412 Precondition Failed. I added jittered backoff — 0–500ms random sleep — and retry up to three times. That eliminated the flakiness.
Pitfall 4: Cohort skew in percent-based rollout
The percent <= 20 condition splits users via a hash of the user ID, not by recency. That means older and newer cohorts may not be balanced inside the 20% bucket. If you're trying to A/B test specifically against new users, you need to combine the percent condition with another condition that limits the cohort. I added first_open_after = "2026-05-10" to the rollout condition so the experiment runs only on freshly acquired users.
What I want to tackle next
The infrastructure is stable, but a few manual steps remain. Reviewing every Slack diff by hand starts to bottleneck once the volume grows. I'm prototyping a "rejection memory" — past rejections fed back to Gemini 2.5 Pro so it can auto-reject obvious misses before they reach Slack.
I'm also planning to extend the same pipeline to my relaxation and meditation apps. The tone definition changes per app, but the pipeline itself transfers cleanly. I expect to discover new pitfalls there, and I'll write up what I find in a follow-up.
Onboarding copy looks like a small thing, but it sits on top of D1 retention, and that single point of retention compounds into meaningful revenue over time. The combination of Gemini 2.5 Flash and Firebase Remote Config is light enough for a one-person team to operate while still producing measurable lift. If you're working on something similar, I hope a piece of this is useful to you.
Share
Thank You for Reading
Gemini 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.