Splitting Daily Crashlytics Triage Across Five Antigravity Sub-Agents
Running six indie iOS and Android apps, the morning Crashlytics triage was draining me. I split the workflow across five Antigravity sub-agents (Fetcher, Classifier, Repro, Patch, PR) and locked their input and output to JSON schemas. Two weeks of production data shows where the human review boundary belongs.
Opening the Crashlytics console every morning and chasing the top ten crashes one by one was the most draining part of running six apps in parallel. As an indie developer running six iOS and Android apps on my own, the morning backlog of crash reports had stopped being something I could handle in a single sitting. For a while I thought handing everything to a single Antigravity agent would solve it, but production use convinced me that a single all-purpose agent is the wrong structure.
So I split the work across five sub-agents. Each one owns a single responsibility, accepts a JSON payload from the previous stage, and emits a JSON payload that the next stage can validate. After two weeks of production use, the morning triage time dropped from 89 minutes to 14, and the false positive rate on suggested patches fell from 18 percent to 6. This is the design, the rationale, and the things I would tell an indie developer who wants to build the same pipeline.
Why a single agent could not carry the whole pipeline
The first attempt was the obvious one: paste the Crashlytics stack trace into a single Antigravity agent and ask it to find the root cause and produce a patch. It works for a week. It breaks within two weeks for three reasons.
First, the context window inflates beyond the agent's ability to prioritize. The crash payload, the surrounding codebase, similar crashes from the past 90 days, the release notes, the dependency diffs — concatenated, this easily exceeds 20K tokens, and the agent starts treating the tail end of the stack trace carelessly. With four or more apps flowing through the same workflow, this got worse.
Second, you cannot decide where humans should step in. Asking yourself "do I trust this agent's patch enough to merge it to main right now?" rarely yields a confident yes. The handful of my apps where AdMob revenue is significant cannot tolerate even a single regression that ripples into a two-digit percent monthly swing. No-review merges were never on the table.
Third, you cannot roll back at the boundary that broke. Was the root cause analysis wrong? Was the repro script too thin? Was the patch applied at the wrong layer? A single-agent log makes that question impossible to answer retroactively.
Splitting into five sub-agents that exchange validated JSON solves all three at once. Each boundary is logged, each stage's failure is identifiable, and each agent's prompt can be tuned without touching the others.
Five sub-agents and their explicit responsibilities
The design is straightforward. Each agent receives a JSON payload conforming to a schema, executes only within its own domain, and emits a JSON payload that the next stage can parse and validate.
Fetcher Agent — pulls crashes from Crashlytics, normalizes them, and outputs structured JSON
Classifier Agent — labels each crash as regression, known-open, external, or device-specific, with a priority score
Repro Agent — drafts repro scripts for the top priority crashes and runs them in a simulator
Patch Agent — produces a minimal patch and a regression test for any successfully reproduced crash
PR Agent — opens a Draft PR, assigns reviewers, and posts a Slack notification
Each agent has an explicit allow list and deny list for what it may include in its output. The Fetcher Agent may forward raw stack traces, but PII fields like user emails or internal user IDs must be stripped at normalization time. This is not just a privacy nicety. It keeps PII out of every downstream log, which matters when you operate apps on both the App Store and Google Play and want to keep audit trails small.
Here is an excerpt from the agents.md that captures the pipeline declaratively for Antigravity to read.
# Crashlytics Triage Pipeline## Agents### fetcher- role: pull last 24h fatal crashes from Crashlytics, emit normalized JSON- inputs: { app_ids: string[] }- outputs: schema://crash-batch-v1.json- tools: gcp-bigquery, firebase-crashlytics-rest- constraints: - strip PII (user_email, custom_keys.user_id) before output - cap at 50 crashes per run### classifier- role: label crashes into 4 buckets and score priority- inputs: schema://crash-batch-v1.json- outputs: schema://crash-triaged-v1.json- tools: codebase-search, git-log-since- constraints: - when labeling as regression, attach the suspected commit hash### repro- role: draft repro scripts for top 5 priority crashes and run them in simulator- inputs: schema://crash-triaged-v1.json- outputs: schema://crash-reproduced-v1.json- tools: xcodebuild-test, gradle-test, simctl- constraints: - if not reproduced in 5 minutes, record the skip reason
Keeping the pipeline declarative in a single agents.md file lets Antigravity load the whole graph as one document. Pairing that with explicit JSON schemas means every stage's output can be validated before the next stage sees it.
✦
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
✦Declarative responsibility split across five sub-agents with locked JSON schemas
✦Scoring logic that decides which patches require human review and which can be auto-flagged
✦Two-week numbers: triage time dropped from 89 to 14 minutes, false positive rate from 18 to 6 percent
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.
Fetcher Agent: pull and normalize from Crashlytics
The Fetcher Agent has two jobs. One is to pull raw data from Crashlytics, either through BigQuery export or the REST API. The other is to strip PII and produce a shape that the downstream stages can rely on.
I run six apps, so the agent reads the target app list from a config file. Apps where AdMob revenue is significant get a lower threshold (more crashes flow through), apps that just launched get a higher threshold (only severe crashes flow through). The app-level weighting lives in config, not in the agent prompt.
# agents/fetcher/main.pyimport jsonimport osfrom datetime import datetime, timedelta, timezonefrom google.cloud import bigqueryPII_FIELDS = {"user_email", "custom_keys.user_id", "device.serial"}def normalize_crash(row: dict) -> dict: """Convert a raw crash row into PII-stripped normalized form.""" return { "issue_id": row["issue_id"], "title": row["issue_title"], "subtitle": row.get("issue_subtitle", ""), "app_id": row["app_id"], "app_version": row["application"]["display_version"], "os": f'{row["os"]["name"]} {row["os"]["display_version"]}', "device": f'{row["device"]["manufacturer"]} {row["device"]["model"]}', "occurred_at": row["event_timestamp"].isoformat(), "fatal": row["is_fatal"], "stack_top": _top_frames(row["exceptions"][0]["frames"], n=8), }def _top_frames(frames: list, n: int) -> list: """Take top n frames, preferring app-owned symbols over vendor frames.""" own_prefix = os.environ["APP_BUNDLE_PREFIX"] own = [f for f in frames if f.get("file", "").startswith(own_prefix)] rest = [f for f in frames if f not in own] chosen = (own + rest)[:n] return [{"file": f["file"], "line": f["line"], "symbol": f.get("symbol")} for f in chosen]def fetch(app_ids: list[str], hours: int = 24) -> dict: client = bigquery.Client() since = datetime.now(timezone.utc) - timedelta(hours=hours) sql = """ SELECT * FROM `crashlytics.firebase_crashlytics.*` WHERE event_timestamp > @since AND is_fatal = TRUE AND application.app_id IN UNNEST(@app_ids) LIMIT 50 """ job = client.query( sql, job_config=bigquery.QueryJobConfig( query_parameters=[ bigquery.ScalarQueryParameter("since", "TIMESTAMP", since), bigquery.ArrayQueryParameter("app_ids", "STRING", app_ids), ] ), ) crashes = [normalize_crash(dict(row)) for row in job.result()] return {"schema": "crash-batch-v1", "fetched_at": datetime.now(timezone.utc).isoformat(), "crashes": crashes}if __name__ == "__main__": out = fetch(json.loads(os.environ["APP_IDS"])) print(json.dumps(out, ensure_ascii=False))
Stripping PII at the entry point means downstream logs are clean by construction. For an indie developer running on both stores, this is a mandatory implementation detail rather than a nice-to-have.
A practical gotcha: Crashlytics stack frames mix your own symbols with SDK internals, and naively taking the top eight frames can leave you with zero app-owned frames. The _top_frames helper prefers app-owned symbols first. After that change, the downstream classifier accuracy improved by roughly 30 percent in my measurements.
Classifier Agent: four buckets and a priority score
The Classifier Agent reads Fetcher's output and labels each crash into one of four buckets.
regression: a file in the top frames was modified in the last 30 days, and this issue_id is new
known_open: the same issue_id has appeared three or more times in the last 90 days
external: the top frame lives in vendor code (Firebase, FBSDK, OS APIs)
device_specific: 80 percent or more of occurrences cluster on one device or OS version
Classification uses Antigravity's codebase search and git log tools. The prompt explicitly demands a 30-day blame for the suspected file, attaching the latest commit author and date to the JSON output.
// agents/classifier/prompt.tsexport const CLASSIFIER_INSTRUCTIONS = `You are a triage classifier for mobile app crashes.For each crash, output one of: - "regression": file modified within last 30 days AND not previously reported - "known_open": same issue_id reported 3 or more times in last 90 days - "external": top frame in vendor SDK (Firebase, FBSDK, etc.) - "device_specific": occurs in 80% or more of same device or OSAppend a priority_score 0-100 weighted by: - regression: base 70 - known_open with recent volume spike: base 60 - external: base 20 - device_specific affecting current OS: base 50Multiply by app weight (provided in input) for revenue-bearing apps.Use codebase-search to verify file existence before classifying as regression.`;
The trap here is that a new agent will conflate "last 30 days of commits" with "last 90 days of reported issues." Make the prompt explicit: if git log --since="30 days ago" -- <file> returns nothing, lean toward known_open rather than calling it a regression. This single clarification cut misclassification noticeably in my runs.
The app-level weight is where I encode the operational priority. Apps where AdMob revenue is significant get a 1.5x weight; apps still in early launch get 0.8x. The result is that the top of the queue reflects what hurts the business most, not what produces the most raw crash counts.
Repro Agent: draft and run repro scripts in a simulator
For the top five crashes, the Repro Agent drafts a script and runs it under xcodebuild test on iOS or ./gradlew connectedAndroidTest on Android. A repro is considered solid only when the crash reproduces three times in a row.
# agents/repro/runner.pyimport subprocessfrom pathlib import Pathclass ReproResult: REPRODUCED = "reproduced" FLAKY = "flaky" NOT_REPRODUCED = "not_reproduced" SKIPPED = "skipped"def run_ios_repro(script_path: Path, scheme: str, timeout: int = 300) -> dict: """Run an iOS repro three times and decide stability.""" successes = 0 failures = 0 for attempt in range(3): try: res = subprocess.run( ["xcodebuild", "test", "-scheme", scheme, "-destination", "platform=iOS Simulator,name=iPhone 15", "-only-testing", str(script_path)], capture_output=True, text=True, timeout=timeout, ) # A crash repro test is supposed to fail. if "Test Suite 'All tests' failed" in res.stdout: successes += 1 else: failures += 1 except subprocess.TimeoutExpired: failures += 1 if successes == 3: return {"status": ReproResult.REPRODUCED, "successes": 3} if successes >= 1: return {"status": ReproResult.FLAKY, "successes": successes} return {"status": ReproResult.NOT_REPRODUCED, "successes": 0}
Three consecutive successes earn the REPRODUCED label. One or two count as FLAKY and force human review downstream. Treating flaky carefully matters in production because a crash that reproduces inconsistently will leave you wondering whether the patch actually fixed it.
A note for indie developers: do not insist on simulator repro for everything. Crashes triggered by device memory pressure or by real-world network latency will never reproduce in a simulator. When the Repro Agent emits NOT_REPRODUCED, it must include the reason so the next stage can switch into a more cautious patching mode.
Patch Agent: a minimal patch plus a regression test
For crashes the Repro Agent confirmed, the Patch Agent produces a minimal patch and an accompanying regression test. This is where Antigravity's strength at codebase-wide diff preview pays off the most.
# agents/patch/prompt_builder.pyimport jsondef build_patch_prompt(crash: dict, repro: dict) -> str: """Build the patch prompt for a reproduced crash.""" return f"""ROLE: You are a senior iOS/Android engineer working with a 12-year indie developer.CRASH:{json.dumps(crash, ensure_ascii=False, indent=2)}REPRO:- script: {repro['script_path']}- status: {repro['status']}- successes: {repro['successes']} / 3REQUIREMENTS:1. Produce a minimal patch that makes the repro script pass (i.e., no crash).2. Add a regression test in the same module as the crashing file.3. DO NOT change unrelated public APIs.4. DO NOT introduce new third-party dependencies.5. If the fix requires architectural changes, output a "needs-discussion" marker instead of code.OUTPUT FORMAT:- patch: unified diff- test: full new test file content- risk_level: "low" | "medium" | "high"- rollback_note: 1-2 sentences describing how to revert"""
I always require risk_level and rollback_note in the output. PR Agent uses both to decide whether the patch is an auto-approve candidate or whether a human must look at it before merge.
Without a strong constraint, the patch agent loves to sneak in unrelated refactors. Bolting DO NOT change unrelated public APIs to the prompt brought the diff scope back to the actual fix. From 12 years of indie work I learned that refactoring and bug fixing should never share a PR — that single rule has prevented more incidents than any tool I have used.
PR Agent: where the human review boundary lives
The PR Agent calls the GitHub REST API and opens a Draft PR. The core of the design is the rule that decides whether the patch goes into the auto-approve-candidate lane or the human-review-required lane.
# agents/pr/decision.pydef decide_review_mode(patch: dict, crash: dict, app_meta: dict) -> str: """Auto-approve candidate only when all three conditions are met.""" if patch["risk_level"] != "low": return "human_review_required" if app_meta["monthly_revenue_jpy"] > 100_000: return "human_review_required" if crash["app_version"].endswith(".0"): # the first dot-zero is dangerous return "human_review_required" return "auto_approve_candidate"
Even in auto-approve mode, I do not actually auto-merge to main. The PR Agent simply moves the Draft PR to Ready for Review and pings Slack with an auto-approve-candidate tag. The merge button is always pressed by a human.
This is obvious once you think about the regression cost on the apps that carry the revenue. Capturing one extra merge per day matters less than preserving one month of release quality. I am willing to wait the extra day.
When the PR Agent moves a PR into the human review lane, it asks a separate runbook agent to attach a comment summarizing similar past incidents. Reviewing crashes late at night is mentally taxing, and even a small reduction in cognitive load makes a real difference.
Two weeks of production numbers
I ran this pipeline through the first two weeks of May 2026 and compared the numbers to the previous two weeks when a single Antigravity agent was handling everything end to end.
Metric
Single agent
Five-agent split
Improvement
Average daily triage time
89 min
14 min
84% faster
False-positive patch rate
18%
6%
67% lower
Crashes auto-reproduced
11%
38%
3.5x
Median time to Draft PR
41 min
9 min
78% faster
Release regressions per month
3
1
67% lower
The biggest single win is the jump in auto-reproduced crashes from 11 to 38 percent. The Repro Agent's narrow focus lets it spend all its context on the repro task, and once a crash is reproduced it can be patched the same day. The auto-reproduction percentage is, in practice, the upper bound on "how many crashes I can close today."
Beyond the numbers, the qualitative change is that my #crashlytics Slack channel quietly updates around 7am with Draft PRs sorted into auto-approve-candidate and human-review-required buckets. Walking to the coffee machine and coming back to a tidy queue is a different experience than the mornings I used to spend chasing crashes by hand.
Indie developer takeaways
A few notes if you want to build the same pipeline.
The smaller each agent's scope, the easier the pipeline is to debug. Knowing which stage failed at a glance is what keeps the system maintainable over months. Greedy agents that try to own three responsibilities become unmaintainable after a few iterations.
Decide the human review boundary along two axes: revenue impact and reproducibility confidence. Apps that materially move the monthly P&L deserve a human eye on every patch, regardless of how low-risk the agent claims the change is.
Lock the input and output of each agent to a JSON schema and persist every payload to logs. Months later, when you tune an agent's prompt, you will need to replay old payloads against the new prompt and diff the outputs. That replay capability disappears if the boundaries are not stable.
The next thing I want to try is wiring the Patch Agent into staged rollouts (10%, 50%, 100%). Medium-risk patches would auto-deploy only to the first stage, with the full rollout held for human approval. That would extend the auto-approve lane safely without giving up the production safety net.
Morning crash triage steals creative time from indie developers if you let it. Splitting the work across narrow sub-agents is one practical way to take that time back. If you are running a similar setup, I would love to hear what works for you.
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.