ClaimGuard — Recover the Revenue Your Denials Are Quietly Costing You
Inspiration
Small medical practices lose $80,000–$150,000 a year to insurance claim denials they never appeal.
Not because the denials are unwinnable — but because the workflow to fight them doesn't scale. The Medical Group Management Association (MGMA) reports that 65% of denied claims are never reworked simply because practices lack the administrative bandwidth. Picture a biller's morning: open the EOB (Explanation of Benefits) PDF, decode a cryptic denial code like CO-197, decide whether to resubmit, appeal, or write the claim off, draft a payer-ready appeal letter, and get it out the door before a short filing deadline slips by. At $25 to $118 in administrative time just to rework a single claim, multiplying that across hundreds of claims a month becomes impossible.
The math is brutal. The revenue is sitting right there — but the manual process is the bottleneck, and most practices simply let it age out unnoticed.
We wanted to turn that entire chain — read → classify → draft → track — into an automated pipeline, so a one-or-two-person billing team can recover money that today just disappears.
What It Does
ClaimGuard automates denial processing end to end:
- Ingest — drag-and-drop an EOB/denial PDF, or forward a denial email to a dedicated inbox.
- Extract & Classify — an LLM pulls out patient, payer, codes, and amounts, then recommends resubmit, appeal, or write-off.
- Draft — when an appeal is warranted, it generates a professional, payer-ready appeal letter you can review and edit inline.
- Track — filing deadlines from the denial date, response windows from submission, and a Needs Action queue so nothing slips through.
- Analyze — denial rate by payer, revenue at risk by category, and recovery tracking over time.
A re-uploaded EOB never double-writes — persistence is idempotent by design.
How We Built It
Backend — FastAPI + LangGraph.
The heart of ClaimGuard is a LangGraph pipeline:
parse_eob → resolve_patient_and_payer → match_or_create_claim → classify_denial → (conditional) draft_appeal → persist
The graph is rebuilt per request so each node closes over a request-scoped database session. The same run_pipeline() entrypoint backs both manual upload and the inbound-email webhook — one pipeline, two front doors.
Provider-Agnostic AI.
Every model call goes through a single abstraction (init_chat_model), so we can swap providers and models via environment variables with no code change. PDF parsing uses Claude's native document block with a pdfplumber text fallback. Extraction and classification use structured output bound to Pydantic schemas — so the LLM returns typed data, not free text we have to wrangle.
Database — Amazon Aurora PostgreSQL.
Aurora is the system of record for every entity: practices, patients, payers, claims, denials, appeals, an append-only activity log, and sales leads. Money is stored as exact NUMERIC to avoid float drift. Uniqueness constraints enforce the claim → denial → appeal lifecycle and power idempotency.
Frontend — Next.js 16.
An App Router (RSC) dashboard with Tailwind v4 and shadcn/ui — analytics, a claims worklist, the needs-action queue, an inline rich-text letter editor, and PDF/DOC export.
Deployment — Infrastructure as Code. The full AWS stack is Terraform + cloud-init: an EC2 instance running FastAPI under systemd, with Caddy reverse-proxying and issuing automatic Let's Encrypt certs. Live at https://apiclaimguard.otito.site, with the frontend on Vercel.
Challenges We Ran Into
Aurora on the Free Tier Is Not Normal Postgres Our first deploy targeted Aurora's free-tier "express configuration" — and it behaves nothing like a standard cluster. There's no VPC and no master password: access goes through a managed internet gateway using short-lived RDS IAM auth tokens minted fresh per connection. Getting this working meant solving three distinct problems:
- The gateway endpoint lives in an
.aws.devDNS zone that some resolvers silently fail to resolve, so we resolve it via public DNS and connect by IP while preserving the hostname for TLS SNI (the gateway routes on SNI). - The gateway reaps idle connections without a clean TCP reset, which hangs a conventional connection pool — so we switched to
NullPool, one freshly-tokened connection per request, with a connect-retry for transient resets. - Every request paid a full cross-region TLS handshake, making latency real and measurable.
We then built a second, production-grade path: Aurora Serverless v2 with standard password auth inside a private VPC, fronted by EC2 + Caddy. The crucial design win was hiding both paths behind a single DB_IAM_AUTH flag — identical application code runs against local Docker Postgres, free-tier IAM Aurora, or VPC password Aurora, with only environment variables changing.
Idempotent Ingestion
Re-processing the same EOB — a re-upload or an email retry — can't create duplicate denials. We guard on (claim_id, denial_code, denial_date) and do match-or-create for patient, payer, and claim in one transaction.
Getting the LLM to Classify Denials Reliably EOB documents are not written for machines. Payer language varies wildly — the same denial reason might appear as CO-197, "not a covered service," or a free-text paragraph buried after three pages of remittance table. Early iterations of our classification prompt would confidently return write-off on claims that were clearly appealable, or miss the denial code entirely.
We went through several prompt iterations before landing on a structure that worked consistently: explicit extraction before classification. We force the model to surface the raw denial code and reason first, then reason about the action. We combined this with a small set of worked examples covering edge cases (like split claims) and a strict output schema that made the model flag "uncertain" rather than guess. The lesson: for document understanding tasks, the order you ask the model to think matters as much as what you ask it.
One Pipeline, Two Front Doors Making manual upload and email-in share the exact same pipeline — rather than drifting into two separate code paths — took deliberate design. The payoff: every ingestion route gets the same extraction, classification, and idempotency guarantees automatically.
What We Learned
- Structured outputs beat prompt-parsing. Binding the model to Pydantic schemas turned "hope the LLM returns clean JSON" into a typed contract. It made the whole pipeline dramatically more reliable.
- LangGraph is the right mental model for AI workflows. Conditional branches (draft only when classified as appeal) and per-request graph construction kept logic readable and testable with a fake LLM — no API key needed in tests.
- Provider abstraction pays off immediately. Being able to swap models from an env var made iterating on cost and quality trivial.
- The cloud's "easy" free tier can be the hardest part. We learned more about RDS IAM auth, SigV4 token signing, DNS/SNI, and connection pooling than we anticipated. The abstraction we built to tame it is one of the parts we're most proud of.
What's Next
The current build closes the loop from the denial received to the appeal letter sent. What we want next is closing the loop from appeal sent to payment recovered — automatically.
That means automated submission via payer portals and clearinghouses (potentially generating X12 EDI 275 transactions for claim attachments): a biller reviews the generated letter, clicks send, and ClaimGuard handles the rest. No copy-pasting into a portal, no manual fax. The entire recovery cycle becomes hands-off.
Beyond that: per-payer response-window tuning based on historical outcomes, and denial-prevention insights that flag the root causes of denials before claims are even filed — turning ClaimGuard from a recovery tool into a revenue protection layer.
Built With
- agentmail
- amazon-web-services
- aurora
- nextjs
- python
- terraform
Log in or sign up for Devpost to join the conversation.