How We Automated iOS Crash Triage with AI — Without Letting It Merge
How we turned crash triage from an overnight fire drill into an automated pipeline that hands engineers a review-ready fix — without ever letting AI merge on its own.
Why crashes are different in healthcare
At Halodoc, a crash isn't a cosmetic bug. A patient mid-teleconsultation, a user trying to order medicine, someone booking a lab test for a sick child — when the app dies on them, we don't just lose a session, we lose trust at the exact moment it matters most. Stability isn't a quality metric for us; it's part of the care.
That raises the stakes on one unglamorous reality of mobile engineering: most crashes surface at the worst possible time. A bad build ships, adoption climbs overnight, and Crashlytics lights up at 3 AM. Someone gets paged, opens a laptop half-asleep, reads a symbolicated stack trace they've never seen, greps through a large codebase, and tries to reason about a root cause under pressure. By the time a fix is understood, written, and reviewed, hours of user impact have already accrued.
We asked a simple question: how much of that could a well-instructed AI agent do before a human even wakes up?
The one thing we would not automate
Before any architecture, we set a hard principle — and it shaped every decision that followed:
AI drafts. Humans approve.
In a healthcare app, an AI agent auto-merging a code change into production is a non-starter. A confident-looking fix that patches the symptom while leaving the real cause in place can be worse than the crash itself — it hides the problem instead of solving it. So by design, the pipeline goes right up to the fix, then hands off to a human rather than merging. The AI does the exhausting groundwork — triage, root-cause analysis, scanning the repo, drafting the fix, and packaging it — and then hands a human a review-ready Merge Request, not a merged change.
Everything below is an "anatomy of the pipeline," but keep this principle in mind: the goal was never to remove the engineer. It was to remove the 3 AM scramble and let the engineer arrive to a clean, well-documented proposal instead of a blank page.
The pipeline at a glance
The workflow chains four systems — Firebase Crashlytics, an MCP integration layer, a Claude Code agent driven by a purpose-built crash-triage skill, and GitLab — into a single automated path from crash detected to MR awaiting review.
The pipeline in four stages: a Crashlytics event triggers the agent, which triages and drafts a fix inside an isolated runtime, opens a Merge Request in GitLab, and stops at a human approval gate — nothing merges without a person.
Let's walk each stage.
Stage 1 — The trigger: a crash becomes a task
The pipeline begins the moment a crash is identified as worth acting on. Instead of a human opening the Crashlytics console, the workflow is kicked off with two inputs: a crash ID and a trigger prompt that tells the agent what to do with it.
Using the Firebase MCP (Model Context Protocol) server, the agent pulls everything a human would normally gather by hand:
- The symbolicated stack trace and the crashing thread
- Crash type and exception signal (e.g.
EXC_BAD_ACCESS (SIGSEGV), nil unwrap →EXC_BAD_INSTRUCTION,SIGABRT) - Device, OS version, and app build metadata
- Frequency and user-impact signals — is this one user, or ten thousand?
MCP is the key that makes this possible: it gives the AI agent a structured, first-class way to read Crashlytics data directly, rather than us scraping dashboards or copy-pasting stack traces into a chat window. The crash stops being a screenshot in a Slack thread and becomes a machine-readable task with a clear entry point.
Most runs start automatically from a Crashlytics webhook/alert — the moment a new crash issue first surfaces, the event fires the pipeline with its crash ID, no human in the loop yet. There's deliberately no volume or severity threshold to cross: we'd rather triage a crash on its first occurrence than wait for it to climb to some impact bar. The second path is a manual kick-off by an engineer, which we kept deliberately: it lets us point the pipeline at an older crash already sitting in the backlog, or re-run one on demand. Same pipeline, two front doors — one reactive, one on-demand.
The pipeline keys off the Crashlytics issue ID, not individual crash events, and skips any crash that already has an open crash-fix/* branch. So a crash spiking thousands of times overnight produces one proposal, not a flood of duplicate MRs in the channel.
A note on trust boundaries. Because a crash payload is untrusted input, this stage is designed defensively. The agent runs in an isolated CI environment with scoped, short-lived credentials — it can push to a crash-fix/* branch and open an MR, and nothing more: it cannot merge, cannot touch protected branches, and has no production access. Just as importantly, the pipeline treats stack traces, logs, and crash metadata as data to analyse, never as instructions to follow — so a hostile string buried in a log line can't steer the agent. The only thing that ever reaches users is a human-approved MR.
Stage 2 — Root-cause analysis via a purpose-built skill
This is where the workflow earns its keep, and it's far more than "ask an LLM about a stack trace."
The agent runs a dedicated, versioned ios-crash-triage skill — a codified procedure that encodes how our senior engineers actually reason about our codebase. It's not a free-form prompt; it's a repeatable pipeline of its own: parse input → classify crash → root cause → fix → ticket.
It reads team context first. Before touching the trace, the skill loads a team-rules.md reference containing Halodoc-specific module owners, known recurring crashes, priority definitions, and conventions. This is what makes an output actionable for us rather than generic advice off the internet.
It parses and classifies deterministically. The skill knows how to read a Firebase report, an Xcode .ips log, or a raw pasted trace — extracting the exception type, reason, crashing thread, and crucially the first com.halodoc frame (the real entry point into our code). It then maps the signal to a crash class using an explicit table:
| Signal / Message | Crash Class |
|---|---|
unexpectedly found nil, EXC_BAD_INSTRUCTION |
Nil / Force Unwrap |
Index out of range |
Array Bounds |
EXC_BAD_ACCESS (SIGSEGV) |
Dangling Pointer / Race |
UI API called on background thread |
Main Thread Violation |
SIGABRT + Swift runtime message |
Assertion / KVO misuse |
It fixes to Halodoc conventions. Each crash class carries a canonical before/after fix pattern in our MVVM style. A force-unwrap crash, for example, isn't just "add a guard" — it's our logging-and-fallback shape:
// ❌ Before
let doctorId = response.data!.doctorId!
// ✅ After — Halodoc pattern: log + graceful error state
guard let data = response.data, let doctorId = data.doctorId else {
HDLogger.log("Missing doctor data in response", level: .error)
showErrorState()
return
}
It's opinionated about the traps that fool humans too. The skill explicitly encodes hard-won rules: the top frame is rarely the bug — look 2–3 frames below; don't add weak self blindly to synchronous closures; as? returning nil is not a fix unless you handle the nil; and never "fix" an unsymbolicated crash by guessing — request a dSYM upload first. These are exactly the mistakes a tired engineer makes under pressure, and baking them into the skill means the AI doesn't repeat them.
Encoding this as a skill rather than an ad-hoc prompt is the whole point: analysis becomes consistent across every crash and every run, and the output is structured enough for a human to review in minutes.
Stage 3 — Locating the code and drafting the fix
With a hypothesis and a target crash class, the agent works inside the actual repository — not from memory. It maps the offending frame to the real file and line, reads the surrounding code to respect existing patterns (our MVVM layering, concurrency conventions, design-system usage), and drafts the change in place.
Because the skill distinguishes the crashing frame from the root cause, the fix targets the origin, not the symptom. A SIGSEGV that surfaces in a Swift runtime frame gets traced back to the missing weak self or the unserialized shared state that actually caused it — a distinction that matters enormously in a large app where corruption often surfaces far from where it began.
Stage 4 — Applying the fix: commit and push, never merge
Once the agent has a fix it's confident in, it acts — but only within safe boundaries:
- Applies the code changes to the correct files
- Commits with a clear, traceable message
- Pushes to a dedicated fix branch
Note what it does not do: it does not merge, and it does not touch master. The branch is a proposal. This is the human-in-the-loop principle expressed directly in git — the AI's work lands somewhere safe and reviewable, and nothing reaches users without a person saying yes.
The fix lands on a predictably named branch — crash-fix/<crash-id> — so it's trivial to trace any branch back to the exact Crashlytics issue that spawned it.
Two things we deliberately keep on the human side of the line:
- Building and testing the fix is the reviewing engineer's job. The agent doesn't self-certify by running the build locally; the engineer who picks up the MR builds it, runs it, and validates the fix as part of review. We'd rather the human's hands touch the verification step than let the AI sign off on its own work.
- Low confidence never becomes a fake fix. When the agent can't form a fix it's confident in, it doesn't guess. Instead it opens a draft MR carrying only the root-cause analysis (no code change), or posts that analysis straight to the team channel with no MR at all — explicitly handing the problem to a human with a head start rather than a wrong patch.
Stage 5 — The Merge Request: a review-ready package
The final stage is what makes the whole thing usable by a busy team. The agent raises a Merge Request that is genuinely reviewable — not a bare diff. It reuses the skill's structured output, so every MR reads the same way:
### 🔍 Root Cause
HDConsultationViewModel.loadDoctor force-unwraps response.data.doctorId.
For expired consultations the API omits `doctor`, so `data` is nil → EXC_BAD_INSTRUCTION.
### ⚠️ Crash Class
Nil / Force Unwrap
### 🛠 Fix
File: HDConsultationViewModel.swift (line 142)
guard-let with logging + error state (see diff).
### 🧪 How to Verify
Open the consultation flow for an expired consultation (nil doctor).
Confirm the error state shows and no crash occurs.
### 🔗 Crashlytics
<link to originating issue> — affected users trend attached.
The MR is then posted automatically into the relevant team channel, so the right engineers see it inside their normal workflow — no dashboard-watching required. From there it's ordinary engineering hygiene: a human reads the root cause, sanity-checks the fix, runs the verification steps, and approves, requests changes, or rejects.
The engineer's job shifts from "investigate this crash from scratch" to "review this proposed fix" — a dramatically faster and lower-stress task — and one that waits for business hours instead of a pager.
A crash crosses our alert threshold and Crashlytics posts a webhook to the pipeline. No human is involved yet — the payload carries the crash issue ID.
The agent pulls the full crash context via the Firebase MCP. The crashing thread tops out in the Swift runtime — not our code:
0 libswiftCore swift_retain
1 com.halodoc.appointments AppointmentDetailViewController.swift:96
closure #1 in AppointmentDetailViewController.bindViewModel()
Aswift_retaincrash onEXC_BAD_ACCESSmaps to the skill's Dangling Pointer / Missing weak self class. The rule is explicit: this signal means a closure capturedselfstrongly andselfwas deallocated before the closure ran. Following frame 1 into the code, the agent finds it:
// AppointmentDetailViewController.swift — the cause
private func bindViewModel() {
viewModel.onDetailLoaded = { data in
self.render(data) // ❌ strong capture in an escaping async callback
}
}
The callback is escaping — it fires when the network load returns. If the user taps back before the load finishes, the view controller is deallocated, but the in-flight request keeps the view model (and its closure) alive. WhenonDetailLoadedfinally fires, it touches freed memory. The crash surfaces inswift_retain; the bug is this capture list. Notably, the skill doesn't sprinkleweak selfeverywhere — its guidance warns against adding it to synchronous closures that can't outliveself. It applies it here precisely because this closure is escaping.
On branch crash-fix/appt-detail-uaf-4821, the agent applies the one-line fix and pushes — it does not merge:// ✅ After
private func bindViewModel() {
viewModel.onDetailLoaded = { [weak self] data in
guard let self else {
return
}
self.render(data)
}
}
The pipeline opens a Merge Request with a full, reviewable description and posts it to the appointments iOS channel:
### 🔍 Root Cause
`AppointmentDetailViewController.bindViewModel()` assigns an escaping callback
(`onDetailLoaded`) that captures `self` strongly. On back-navigation the view
controller is deallocated, but the in-flight request keeps the view model and
its closure alive. When the load completes, the closure touches freed memory →
use-after-free surfacing in `swift_retain` (EXC_BAD_ACCESS).
### ⚠️ Crash Class
Dangling Pointer / Missing weak self
### 🛠 Fix
File: AppointmentDetailViewController.swift (line 96)
Capture `self` weakly in the escaping callback and guard before use (see diff).
### 🧪 How to Verify
Open appointment detail and immediately tap back before it finishes loading.
Repeat several times with Zombie Objects enabled. Confirm no crash.
### 🔗 Crashlytics
<link to originating issue> — affected-users trend attached.A reviewer reads the root cause, reproduces the race, runs the fix, and merges.
The whole point: at the crash site there's nothing to fix — swift_retain is Apple's code. A from-scratch investigation starts by staring at a frame that isn't the bug. The pipeline starts from the rule, walks to the real capture, and hands the reviewer a one-line fix with a repro.Why this is safe — and why that matters
"AI fixes crashes" can sound reckless in a healthcare context — but the human review gate is only half the story. Three properties of the pipeline itself make each proposal fast and safe to trust:
- Full transparency. The MR shows its work — root cause, reasoning, and verification steps — so a reviewer can catch a symptom-fix or a wrong hypothesis quickly.
- Reproducible process. Because triage is a skill with an explicit classify → fix flow, its output is consistent and auditable rather than a different guess each time.
- Honesty about uncertainty. On unsymbolicated traces or low-confidence cases, the skill is instructed to say so and ask for dSYMs — not to fabricate a fix.
The AI didn't replace the reviewer. It removed the toil that used to sit between a crash and a reviewable fix.
What we gained
In the two months since launch, the pipeline has drafted more than 100 review-ready merge requests — triaging each crash around the clock, before anyone opens Crashlytics. Rather than chase a single headline number, the wins showed up across the way the team works:
- Faster time-to-fix. Root-causing a crash by hand used to take an engineer roughly two hours. The pipeline now delivers a review-ready MR — with the root-cause analysis already written — in 15–20 minutes, so the investigation is finished before the team logs in.
- ~1.5 engineer-hours saved per crash. These are engineer estimates self-reported across the pipeline's early runs, not instrumented timings — and the ~1.5 hours saved is net: the ~2 hours of manual investigation minus the 15–20 minutes a reviewer now spends on the AI's MR.
- Fewer overnight fire drills. Crashes that land while the team sleeps are triaged and drafted automatically; no one is paged just to understand a stack trace anymore.
- Consistent quality. Every crash gets the same rigorous, convention-aware analysis — the pipeline doesn't have off days or tired mornings.
We're honest about the number that actually matters: how often a reviewer accepts the AI's fix as-is versus edits or rejects it. We track that acceptance rate internally but aren't publishing a figure yet — the sample is still small enough that a percentage would imply more precision than we have. What we can say is directional: the majority of AI-drafted MRs merge with edits rather than being rejected, and the trend is what we manage to. It isn't 100%, and we don't want it to be — a rejected MR still saved the engineer the triage, because even a rejected proposal almost always arrives with a correct root cause. We watch that rate as our primary quality signal, precisely because a fast pipeline that drafted wrong fixes would be worse than no pipeline at all.
Limitations
We're deliberate about where this pipeline stops being the right tool. The honest edges:
- Not all crashes are automatable. The pipeline's sweet spot is deterministic app-code crashes with clear signatures — nil unwraps, array bounds, main-thread violations, obvious dangling references. Concurrency heisenbugs, memory-pressure kills, and SDK-internal crashes still need humans. There, the pipeline's job is to route them well — surface the trace, classify what it can, and flag low confidence — not to force a fix.
- The proposal isn't build-verified. The agent drafts a fix but doesn't compile or run it — so an MR can arrive syntactically plausible yet fail to build. We accept this trade-off deliberately: build-and-test is the reviewer's step and our signal that a human genuinely engaged. But it means "review-ready" is analysis-ready, not green-CI-ready. Gating each MR on its own CI run before a human sees it is on our roadmap.
- The skill is a living dependency.
ios-crash-triageencodes our conventions, and those conventions move. As the codebase evolves — SwiftUI adoption, the Swift Concurrency migration, new module ownership — the skill has to evolve with it, or fix quality silently degrades. An out-of-date skill doesn't fail loudly; it just starts drafting fixes in yesterday's patterns. We treat the skill as code that needs maintenance, not a one-time setup. - Cost per run is real. Repository scanning and rich crash context consume meaningful tokens per crash. At our crash volume this is comfortably cheaper than engineer-hours — but it isn't free. Teams with very high crash rates should model the economics before adopting, rather than assume it always pays back.
- Automation complacency is the biggest long-term risk — and it's social, not technical. The failure mode isn't a bad diff; it's reviewers rubber-stamping AI MRs because "the AI is usually right." A convincing, well-formatted MR is easier to wave through than a human's, which is exactly the danger. We treat review rigor on AI-authored MRs as a norm to actively maintain, not an assumption we can coast on. The human-in-the-loop gate only works if the human stays genuinely in the loop.
Where we're headed
The next step we're most excited about is generating a regression test alongside each fix. Right now the pipeline produces a fix and verification steps; the natural evolution is for it to also write an automated test that reproduces the original crash and proves it can't come back. That closes the loop in the most durable way possible — every crash we fix leaves behind a permanent guard against its own recurrence, so the same bug can't quietly return in a future refactor. A fix protects users today; a regression test protects them for every release after.
The bigger lesson for us: the highest-leverage place for AI in a healthcare engineering org isn't replacing the engineer — it's collapsing the distance between a problem being detected and a human having everything they need to solve it, safely. Crash triage was our proving ground. The overnight fire drill is optional now.
Conclusion
Crash triage used to be one of the most reactive, high-stress parts of running an iOS app at scale — the kind of work that lands at the worst hour and demands the most context. By chaining Firebase Crashlytics, an MCP integration layer, a purpose-built ios-crash-triage skill, and GitLab into a single pipeline, we turned that fire drill into a repeatable, reviewable process.
A few takeaways if you're considering something similar:
- Automate the toil, not the judgment. The pipeline does triage, root-cause analysis, and drafting; the merge decision stays with a human. In healthcare that boundary isn't a limitation — it's the whole design.
- Encode expertise as a skill, not a prompt. A versioned, convention-aware skill is what makes the output consistent, auditable, and actually usable — and it's a living dependency you maintain like code.
- Ship the fix as a review-ready package, not a diff. Root cause, reasoning, and verification steps turn "investigate from scratch" into "review this proposal" — the single biggest lever on speed and stress.
- Guard against complacency. The gate only works if reviewers stay genuinely engaged; we treat rigor on AI MRs as a norm to defend, not assume.
The result isn't just faster fixes. It's a calmer, more consistent way to protect the stability our users depend on — without asking anyone to reason about a stack trace half-asleep.
References
- Halodoc AI Skills — CrashTriage (GitHub)
- How We Generate Production SwiftUI from Figma Using AI Skill at Halodoc
- Automating iOS Memory Leak Detection: Designing a Runtime Observability Pipeline
Join us
Scalability, reliability, and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels, and if solving hard problems with challenging requirements is your forte, please reach out to us with your resume at careers.india@halodoc.com.
About Halodoc
Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke.
Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.
We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalized healthcare solutions — and we remain steadfast in our journey to simplify healthcare for all Indonesians.