GEMINI LABJP
SCALE — The Gemini app passed one billion monthly users on August 11. Consumer adoption has settled, and developer attention is shifting to how to build on top of itASSISTANT — Gemini replaces Google Assistant on Android from September 4. Fifteen days out, so apps wired into App Actions or voice shortcuts should be checked nowAGENTS — Managed Agents in the Gemini API entered public preview, letting you build stateful autonomous agents inside a secure Google-hosted environmentENTERPRISE — Gemini Enterprise reached general availability for registering and managing A2UI and A2A agents, moving agent-to-agent wiring out of previewSUNSET — gemini-robotics-er-1.6-preview shuts down on August 31, eleven days out. The replacement is Gemini Robotics ER 2, in public preview since July 30PRICING — Gemini 3.7 Flash went GA on August 13 at an introductory $0.75 per million input tokens and $3.75 output, holding through December 31, 2026SCALE — The Gemini app passed one billion monthly users on August 11. Consumer adoption has settled, and developer attention is shifting to how to build on top of itASSISTANT — Gemini replaces Google Assistant on Android from September 4. Fifteen days out, so apps wired into App Actions or voice shortcuts should be checked nowAGENTS — Managed Agents in the Gemini API entered public preview, letting you build stateful autonomous agents inside a secure Google-hosted environmentENTERPRISE — Gemini Enterprise reached general availability for registering and managing A2UI and A2A agents, moving agent-to-agent wiring out of previewSUNSET — gemini-robotics-er-1.6-preview shuts down on August 31, eleven days out. The replacement is Gemini Robotics ER 2, in public preview since July 30PRICING — Gemini 3.7 Flash went GA on August 13 at an introductory $0.75 per million input tokens and $3.75 output, holding through December 31, 2026
Articles/Dev Tools
Dev Tools/2026-08-20Beginner

A minimal pre-send guard for Gemini-drafted app review replies

Store replies are risky after you hit send, not before. Here is a short Python guard that blocks unkeepable promises, leaked contact details, and register slips, plus the two false positives it produced on the first run.

Gemini API216Indie development3App operationsStructured outputPython42

I once started writing a reply to a one-star review late at night.

"It crashes every time I open settings." I knew the bug. Before I noticed, I had typed "we will fix this in the next update," and I stopped with my cursor on the send button. Nothing at that moment guaranteed the fix would make the next build.

Gemini can draft that reply in seconds. The faster the drafting gets, the less recoverable the send becomes. Store replies are public, and a promise stays readable until the next version actually ships.

So before making the drafting smarter, I built the part that stops a reply from going out. The code is short and unglamorous. But when I ran it, the thing that turned out to be wrong was the guard, not the draft — and that part is worth showing.

Decide whether to reply before writing anything

If you answer reviews in the order they arrive, you run out of time for the ones that matter. So the sorting happens before any text is generated.

BucketConditionWhat happens next
priority2 stars or fewer, with something resembling repro stepsDraft it, and read the draft yourself
ack_only3 stars or fewer, no repro stepsSend a short acknowledgement
no_reply4 stars or moreDon't spend a reply here

Skipping happy reviewers may look cold. But the people whose opinion actually moves when they read a reply are the ones currently stuck. That is where the limited time goes.

import re
 
REPRO = r"(when I|after I|every time|crash|freeze|won't open|can't save|doesn't load)"
 
def triage(stars: int, review: str) -> str:
    repro = re.search(REPRO, review, re.IGNORECASE)
    if stars >= 4:
        return "no_reply"        # don't spend a reply here
    if stars <= 2 and repro:
        return "priority"        # repro steps present, handle first
    return "ack_only"            # acknowledge and move on

The only signals here are the star rating and symptom words. Emotional intensity is deliberately ignored. A calmly written report of exact steps usually carries more fixable information than an angry one.

Receive the draft as structure, not prose

Ask Gemini for "a reply" and you get one continuous paragraph. Feeding that into a guard means re-parsing which sentence is an apology and which one is a commitment, every single time.

Splitting it at the source makes everything downstream easier.

from google import genai
from google.genai import types
 
client = genai.Client(api_key="YOUR_API_KEY")
 
REPLY_SCHEMA = {
    "type": "object",
    "properties": {
        "acknowledgement": {"type": "string"},   # we heard you
        "status": {"type": "string"},            # only what is known today
        "next_step": {"type": "string"},         # what we ask of the reviewer
    },
    "required": ["acknowledgement", "status"],
}
 
def draft(review: str, mode: str) -> dict:
    prompt = (
        "Draft a reply to this app store review. Stay polite and concise, "
        "and never state a fix date that has not been committed.\n"
        f"Bucket: {mode}\nReview: {review}"
    )
    res = client.models.generate_content(
        model="gemini-3.7-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema=REPLY_SCHEMA,
        ),
    )
    return res.parsed

Keeping status separate is the point. "We are investigating the cause" is a fact; "we will fix it" is a commitment. Blend them into one paragraph and you can no longer tell them apart afterwards. If the schema itself gives you trouble, common Structured Output validation errors and how to resolve them collects the usual failure shapes.

And telling the model not to promise a date does not mean it never will. That is why the guard exists.

Three checks before send

Only three things need to be stopped: promises you cannot keep (A), contact details leaking into a public reply (B), and tone or length problems (C).

MAX_CHARS = 350  # my own readability limit, separate from any store limit
 
PROMISE = [r"次の(アップデート|バージョン)で", r"必ず(修正|対応)", r"すぐに(修正|対応)",
           r"\d+\s*(|週間|)以内に", r"来週(まで)?に", r"今月中に", r"予定です"]
# Japanese has an explicit polite register. Confirm it first, then suspect the plain form.
POLITE_END = re.compile(r"(です|でした|ます|ました|ません|ましょう|ください|ございます)$")
PLAIN_END = re.compile(r"(|である|した|ない|いる|する||)$")
 
def mask(draft: str) -> str:
    """Blank out emails and URLs first, or the handle pattern fires twice on the same span"""
    d = re.sub(r"[\w.+-]+@[\w-]+\.[\w.]+", "\u3000", draft)
    return re.sub(r"https?://\S+", "\u3000", d)
 
def check(draft: str) -> list:
    hits = []
    for p in PROMISE:
        if re.search(p, draft):
            hits.append(("A_promise", p))
    for pattern, target in ((r"[\w.+-]+@[\w-]+\.[\w.]+", draft),
                            (r"https?://\S+", draft),
                            (r"@[A-Za-z0-9_]{3,}", mask(draft)),
                            (r"0\d{1,4}-\d{1,4}-\d{3,4}", mask(draft))):
        m = re.search(pattern, target)
        if m:
            hits.append(("B_contact", m.group(0)))
    for sentence in re.split(r"(?<=)", draft):
        sentence = sentence.strip()
        if sentence and not POLITE_END.search(sentence) and PLAIN_END.search(sentence):
            hits.append(("C_plain", sentence))
            break
    if len(draft) > MAX_CHARS:
        hits.append(("C_length", f"{len(draft)} chars"))
    return hits

Check B exists because a public reply that routes someone to a support address leaves that address sitting in public forever. The instinct to help one person individually is right; the place to do it is not the store listing.

Running it exposed two bugs — in the guard

I prepared six review-and-draft pairs and ran the first version as written. The drafts are Japanese, since that is the locale I actually reply in, so check C evaluates the Japanese polite register. The output was this:

★  bucket     verdict  hits
1  priority   STOP     A_promise:次の(アップデート|バージョン)で
1  ack_only   STOP     B_contact:support@example.com / B_contact:@example
2  ack_only   OK       —
2  priority   STOP     C_plain:保存が完了しない事象を確認しました。
5  no_reply   —        reply slot not used
3  ack_only   STOP     C_plain:ご意見に感謝する。
 
6 drafts → 5 in scope / 1 passed / 4 blocked / 1 skipped

Two of those four blocks were the guard's own mistakes.

The first is on line two. B_contact fires twice for support@example.com, because the handle pattern picked up @example inside the email address. One span, counted twice.

The second one actually hurts. On line four, a perfectly polite sentence was blocked as plain-form, because the rule searched for a plain-form ending (した。) that also sits inside the polite ending (ました。). Ordering was the problem: the guard has to confirm the polite form first and only then suspect the plain one, not the other way around. The same trap exists in any language where the formal marker is a superset of the informal one.

Two fixes: mask emails and URLs before looking for handles, and evaluate the polite ending before the plain one. Re-running gives:

1  priority   STOP     A_promise:次の(アップデート|バージョン)で
1  ack_only   STOP     B_contact:support@example.com
2  ack_only   OK       —
2  priority   OK       —
5  no_reply   —        reply slot not used
3  ack_only   STOP     C_plain:ご意見に感謝する。
 
6 drafts → 5 in scope / 2 passed / 3 blocked / 1 skipped
MetricBeforeAfter
Blocked43
False positives2 (one double count, one register misread)0
Passed12

All three remaining blocks were correct: an uncommitted fix date, an address I did not want published, and a sentence that dropped out of the register I use with reviewers.

A guard that cries wolf gets ignored, and an ignored guard eventually gets deleted. Laying six cases out by hand while the numbers are still small is cheap insurance against that path.

What to delegate, and what to keep

Drafting and mechanical checking are automated on my side. Pressing send is not.

The reason is simple: a reply contains commitments. "We are investigating" reports a fact, but "we will fix this" agrees to a development plan. Only the person who knows what gets worked on next can make that call, and that has nothing to do with how well the sentence is written.

The same logic keeps judgement about the review itself in human hands. The buckets inform me; they do not decide for me. A reply written without reading the review reads exactly like one.

Once you are replying in several languages, other concerns appear — terminology consistency, pacing the volume, register per locale. I wrote up an actual multilingual run in building Google Play replies in eleven languages with Gemini, which is worth a look once your reply volume grows.

One thing to try next

Add a single phrase to the PROMISE list: one you have personally regretted sending. A generic banned-words list helps less than removing one of your own habits. Mine was "shortly."

It is a small guard. But having one stage in front of the send button means the version of you that writes at midnight gets checked by the version that reads at breakfast.

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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-05-03
Build a CSV Insight App with Gemini API and Streamlit — A Production-Ready Dashboard with Auto-Insights and Visualization
A production-grade implementation guide for a Streamlit + Gemini API data analysis app. Upload a CSV, get auto-insights and visualizations in seconds. Covers schema inference, structured output, and real-world rate-limit handling.
Dev Tools2026-08-17
Your Shipped App Still Remembers the Retired Model
Finishing the server-side migration is only half of a model retirement. The older builds of your app still hold the old model name, and once the cutoff passes, rolling back stops being a recovery option. Here is how I moved model resolution onto the server.
Dev Tools2026-08-05
Green Tests, Dead Production — How Recorded Fixtures Hide a Model Retirement, and a Freshness Gate to Catch It
A test suite that replays recorded API responses will sail straight past a model retirement. I reproduce the failure in a minimal setup and build a cassette freshness gate, with measured overhead.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →