I wanted to read App Store Connect numbers each morning not as a spreadsheet but as a few honest sentences. For an indie developer who has been building iOS and Android apps since 2014, this small wish finally took shape with Gemini 2.5 Flash. These are the notes from two months of running it.
Why I wanted prose, not rows, in my morning Slack
I run a portfolio of wallpaper and well-being apps that has grown to roughly 50 million cumulative downloads on iOS and Android, all as a single person. AdMob is the main revenue pillar, but paid downloads and in-app purchases move at a scale I cannot afford to ignore. Regional shifts and OS-version-level trends are the kind of thing I want to glance at every day.
For years my morning ritual was: download yesterday's Sales/Trends CSV, open it in Numbers, and mentally walk through "what changed since yesterday." Reading numbers is not unpleasant, but spending the first quarter-hour of the day on it felt wasteful. I wanted the same overview in a shorter, gentler form.
So I put Gemini 2.5 Flash on top as a summarization layer, and now the morning channel shows numbers and a one-paragraph reflection side by side. This article is an honest review after running that for two months.
The shape of the pipeline
A Cloudflare Workers Cron job fires at 7:30 JST and does roughly this:
# scripts/daily_revenue_digest.py (excerpt)
import os, json, datetime
from google import genai
from appstoreconnect_client import fetch_sales_report
from slack_sdk import WebClient
YESTERDAY = datetime.date.today() - datetime.timedelta(days=1)
WINDOW = 14
def build_prompt(today_rows, trend_rows):
return f"""
You are an operations partner for an indie developer.
Below is yesterday ({YESTERDAY}) raw sales for an iOS / Android portfolio,
plus a slim trend of the last {WINDOW} days.
Write a reflection in English: at most three paragraphs of 150 chars each.
Rules:
- include at most three numbers in the body
- start with a one-line label: NORMAL / SLIGHTLY OFF / CHECK
- write in a calm tone for someone reading at breakfast; no hype words
[yesterday]
{json.dumps(today_rows, ensure_ascii=False)}
[last {WINDOW} days]
{json.dumps(trend_rows, ensure_ascii=False)}
"""
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=build_prompt(today_rows, trend_rows),
config={"temperature": 0.3, "max_output_tokens": 600},
)
WebClient(token=os.environ["SLACK_BOT_TOKEN"]).chat_postMessage(
channel="#dolice-morning",
text=resp.text,
)There are only two real prompt rules. One: at most three numbers in the body. Two: no hype words. The first one stops the summary from collapsing back into a number dump. The second one quietly suppresses the "dramatic surge" and "stunning growth" phrasings that 2.5 Flash sometimes reaches for, which are not the tone I want in a breakfast Slack.
Why Flash, not Pro, for this job
I spent the first month on Gemini 2.5 Pro. The prose was lovely, but for a three-paragraph 150-character format it was clearly overkill, and the monthly API bill landed near four times the Flash equivalent.
App Store Connect daily sales are well-structured tabular data with an obvious comparison point: the previous day and a 14-day baseline. Flash handles the judgment "today is below the recent average for Germany" without drama. With temperature pinned at 0.3, I have not seen a single runaway summary in two months.
Pro still lives in my Monday morning script. Once a week, it reads seven days of Sales/Trends and the AdMob report side by side and writes a 200-character reflection on "what grew this week" and "which regions need watching next." The split that has settled in: Pro for thinking, Flash for reading.
Passing in a 14-day trend made the read steadier
The first few weeks I passed Flash only yesterday's data. The summaries looked clean on the surface, but the "normal / slightly off" verdicts drifted day to day. Flash had no idea what counts as normal for this portfolio, which is fair.
The fix was to slim down the last 14 days of records and pass them as a side channel. Just country-level downloads, just purchase-type revenue, lightly aggregated:
def slim_trend(rows, days=14):
by_country = {}
for r in rows:
key = r["country"]
by_country.setdefault(key, []).append(r["amount_jpy"])
return [
{"country": k, "avg": round(sum(v)/len(v)), "n": len(v)}
for k, v in by_country.items()
]Average and sample count, nothing fancy. The reflection became visibly steadier: "Germany averages 1,820 JPY across 14 days; today is 2,140." The day-over-day math I had been doing in my head mostly disappeared. Token costs rose by about 20%, which felt cheap for the increase in reliability.
Where AdMob and store revenue collided
App Store Connect summaries stabilized fast. The wobble started when I joined AdMob reports onto the same prompt. AdMob eCPM is reported in USD; App Store Connect revenue lands in whatever currency the store uses. Flash started mixing units, putting "+18%" next to "+22 yen" and producing reflections that quietly conflated the two.
The repair was boring and lived on the Python side. I converted everything to JPY before sending, and kept the original currency only as metadata if I needed it later.
def normalize_revenue(rows, fx_table):
out = []
for r in rows:
jpy = r["amount"] * fx_table.get(r["currency"], 1.0)
out.append({
"country": r["country"],
"amount_jpy": round(jpy),
"currency_original": r["currency"],
})
return outReaching for the LLM to do currency normalization felt elegant but kept producing "FX moved, model thinks revenue spiked" incidents. Pre-normalizing in code is a small cost for a much calmer Slack feed.
The three days the summary slipped, and the label that fixed them
Across two months, Flash produced a clearly wrong reflection on three days. Each time the cause was App Store Connect partial reporting: yesterday's data arrived only half-filled, and Flash read the gap as a sudden drop.
Two changes settled it. First, every Sales/Trends record now carries an end timestamp, and Flash receives an explicit "completeness: preliminary / final" tag. Second, every reflection starts with a one-line label: NORMAL, SLIGHTLY OFF, or CHECK.
def annotate_completeness(rows):
return [
{**r, "data_state": "preliminary" if r["row_count"] < r["expected"] * 0.9 else "final"}
for r in rows
]With the label up front, my morning reading time dropped to about 15 seconds on normal days. I look at the label, decide whether I need the three paragraphs, and keep my coffee. The point of the digest turned out to be not "always read a reflection" but "earn more mornings where I do not need to dive in."
Two months in: where Flash is the right partner, and where it is not
Flash has become a comfortable presence in my morning channel. At the same time, it is a smoothing layer over the numbers, not a strategist. It can surface a fact like "German-language regions have grown wallpaper downloads two weeks in a row," but it cannot decide whether to ship a new color palette to match.
Once I accepted that boundary, the layers settled: the daily Slack is a place for reassurance, and the weekly Pro report is a place for thinking. Ever since I taught myself programming as a 16-year-old in 1997, the small independence of one-person operations has been my baseline. The new layer of "reading numbers gently" is a quiet addition, not a replacement.
If you want to try the same shape tomorrow morning, I would start with the smallest possible piece: Cloudflare Workers Cron, Gemini 2.5 Flash, and a Slack message of 150 characters. Add one line to the prompt about avoiding hype words. That alone changes the texture of the morning more than I expected.
Thank you for reading.