PriAuthra — A Prior-Auth Agent That Shows Its Work
A multi-agent system that automates the healthcare prior-authorization workflow — checking eligibility, matching submitted documentation against a payer's actual policy text, and drafting grounded appeals on denial — with a human reviewing and approving every decision before anything reaches a payer.
The problem & requirements
- Check coverage and whether PA is even required for the submitted procedure/diagnosis code — callable per case, not a blanket assumption
- Retrieve the payer's actual policy text and assess submitted documentation against it — matched criteria, gaps, and citations, not a bare pass/fail
- Draft an appeal on denial grounded in the same citations and specific gaps found, not a generic denial-response template
- Grounding, not assertion: no clinical-criteria claim shown without a citation and similarity score tied to retrieved policy text
- Human-in-the-loop: nothing reaches a payer automatically — a reviewer approves, edits, or denies every case
- Isolation by construction: a case for one payer structurally cannot retrieve another payer's criteria
Scale & constraints
An MVP build: single developer, free-tier infrastructure everywhere except Anthropic, no BAA yet with any vendor. The numbers below are what the architecture had to hold under from day one — a hard gate on real PHI, not a scale target.
API design
Data model
| Entity | Key fields |
|---|---|
| PriorAuthRequest | Core case record. PK doubles as the LangGraph `thread_id`; its status enum drives supervisor termination. |
| Patient | Minimized fields only — MRN encrypted at the field level, DOB, plan foreign key. |
| EligibilityCheckResult | Coverage / PA-required outcome per request. |
| ClinicalCriteriaAssessment | Matched criteria, gaps, citations, and confidence from the clinical-criteria node. |
| AppealCase | Draft letter, status (`drafting` / `reviewed` / `submitted` / `denied`), outcome. |
| AuditLog | Actor, role, action, resource, IP, timestamp — written on every PHI-touching read/write. |
| PARunState (LangGraph) | In-flight working memory only — `request_id`, `documents` (refs, not raw text), `eligibility_result`, `criteria_assessment`, `next_step`. |
The API and frontend always read the domain tables, never live graph state — if the two ever disagree, the domain tables win. PARunState is checkpointed for resumability, not queried directly by anything a reviewer sees.
Architecture
Not every case needs all three specialists, and order isn't fixed — the supervisor inspects shared state and routes dynamically instead of following a linear pipeline.
Fig. 1a — The supervisor cycle repeats until case status reaches a terminal value or the recursion limit forces an exit.
Every layer runs on free-tier infrastructure except Anthropic — a hard constraint on cost, and on what data is allowed to flow through it until BAAs are in place.
Fig. 1b — Backend cold-starts after ~15 min idle on Render's free tier; an accepted trade-off for an MVP demo.
Key decisions & trade-offs
PARunState, so it structurally can't appear in a LangSmith trace.Guardrails
An appeal can't be approved without an attestation checkbox — "submission" is a status change plus the letter, never automatic payer transmission.
Every clinical-criteria and appeal claim is tied to retrieved policy text with a citation and similarity score, shown in the UI as its own tool block.
A hard recursion limit fails a case to needs_review instead of looping indefinitely if the supervisor bounces between specialists.
Every read/write touching a PriorAuthRequest, Document, or AppealCase writes an AuditLog row — actor, role, action, resource, IP, timestamp.
Providers see only their own requests; a provider probing another provider's request id gets a 404, never a 403.
Raw clinical documentation structurally can't appear in a LangSmith trace — it's never part of traced graph state.
Lessons learned
- Per-node tool binding as a structural restriction, not a prompted one, caught nothing in testing — but it's the reason a node can never call a tool it shouldn't, independent of what the prompt says.
- Keeping raw clinical text out of graph state entirely meant no extra filtering logic was needed when LangSmith tracing came online later — PHI was already structurally excluded, not scrubbed after the fact.
- Retrying the whole call, not just the request, in
call_with_tool_retryfixed a real, live-only bug where a forceddraft_appealtool call intermittently omitted a required field.
- The payer-identifier bug — a UUID FK threaded into graph state instead of the payer's string slug — silently returned empty retrieval for every real request, and wasn't caught until a live end-to-end run against real Pinecone.
- LangSmith tracing defaults off locally until a BAA is confirmed, which is the right safety call but slowed down actually watching agent traces during early development. I'd stand up a throwaway non-PHI tracing project sooner.
- No automated frontend test framework yet — verified instead by driving the full flow in a real browser, which worked but isn't repeatable the way Playwright would be.
payer_id as a typed, validated field into graph state from day one, instead of discovering the wrong identifier only when live retrieval silently came back empty.