ProjectsPriAuthra — Prior-Authorization Agent
Case Study · Healthcare AI · Agentic Orchestration

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.

LangGraphFastAPIClaudePineconeNext.jsVoyage AINeon PostgresLangSmith
Role
Full-stack build, agent orchestration & compliance design
Domain
Healthcare · prior authorization
Primary Services
Claude API · LangGraph · Pinecone
01

The problem & requirements

Functional
  • 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
Non-functional
  • 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
02

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.

15
hard recursion limit before a case fails to needs_review
99
backend tests passing, ruff/mypy clean
2
real, live-only bugs found and fixed via full-loop testing
03

API design

POST /pa-requests/{id}/run → 202 Accepted
Starts the LangGraph state machine for a case. Supports an `Idempotency-Key` header so a retried request can't double-trigger a run.
GET /pa-requests/{id}/stream → SSE stream
Live node start/end events as the supervisor routes the case: `{type: node_start|node_end|message|error|done, node, payload, timestamp}`.
POST /internal/policies/search → PolicyMatch[]
Internal-only. Filters Pinecone retrieval server-side by `payer_id` + `procedure_code` pulled from the case record, never from LLM-generated query text.
PATCH /appeals/{id} → AppealCase
Edit a drafted appeal or set `status: submitted|denied`. Requires an attestation flag before a submitted status is accepted.
POST /eligibility/check → EligibilityCheckResult
Confirms coverage and whether PA is required at all for this procedure — a mocked payer-clearinghouse call in this MVP, swappable for a real X12 270/271 integration later.
04

Data model

EntityKey fields
PriorAuthRequestCore case record. PK doubles as the LangGraph `thread_id`; its status enum drives supervisor termination.
PatientMinimized fields only — MRN encrypted at the field level, DOB, plan foreign key.
EligibilityCheckResultCoverage / PA-required outcome per request.
ClinicalCriteriaAssessmentMatched criteria, gaps, citations, and confidence from the clinical-criteria node.
AppealCaseDraft letter, status (`drafting` / `reviewed` / `submitted` / `denied`), outcome.
AuditLogActor, 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.

05

Architecture

Case routing flow

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.

📋
Case created
Supervisor
specialist node
Supervisor
end
🩺
Eligibility node
Confirms coverage and whether PA is required (mocked payer-clearinghouse call in this MVP)
🔍
Clinical-criteria node
Pinecone RAG retrieval of payer policy text, filtered server-side by payer + procedure code
✍️
Appeals node
Drafts a letter grounded in persisted citations — reuses retrieval, never re-queries
Routing is a real Claude call with forced structured output — not parsed free text, not a hand-rolled if/else — so a decision can't silently misfire. Each node is bound to its own tool list at graph-construction time; only the service layer holds API credentials.

Fig. 1a — The supervisor cycle repeats until case status reaches a terminal value or the recursion limit forces an exit.

Deploy & observability topology

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.

Next.js
Vercel
FastAPI
Render
🐘
Neon Postgres
📦
Cloudflare R2
🔎
Pinecone
🛰
LangSmith tracing
🔁
GitHub Actions CI/CD
The frontend never talks to Anthropic, Pinecone, or the eligibility API directly — only the backend does, keeping every third-party credential server-side. Migrations run as a distinct pre-deploy CI step, never inline at app startup.

Fig. 1b — Backend cold-starts after ~15 min idle on Render's free tier; an accepted trade-off for an MVP demo.

06

Key decisions & trade-offs

Trade-off. Supervisor + specialists, not a fixed pipeline. Case flow branches — eligibility-only, denial → appeal, missing docs → awaiting input — a supervisor routing dynamically off shared state fits that; a linear chain doesn't.
Invariant. Retrieval scoped from the case record, never from LLM-generated text. The Pinecone filter comes from the DB record server-side, so a case for payer X structurally cannot retrieve payer Y's criteria — no prompt can override this.
Constraint. Raw clinical text as a closure argument, not graph state. PHI is passed directly to the two nodes that need it rather than stored in PARunState, so it structurally can't appear in a LangSmith trace.
Bootstrap-first. No Redis or worker fleet for the MVP. Runs execute inline in the async request handler; LangGraph's Postgres checkpoint makes a crashed request resumable without a queue — revisit only if multi-instance SSE fan-out becomes necessary.
Trade-off. Voyage AI for embeddings, not Anthropic-direct. Anthropic has no embeddings endpoint; Voyage is Anthropic's recommended pairing, with OpenAI as fallback.
Verification. Proved the full loop live against real vendors, not just test fakes. Driving the actual frontend in a headless-Chromium session against real Anthropic, Pinecone, and Voyage AI surfaced two real bugs unit tests hadn't caught.
07

Guardrails

Human-in-the-loop before anything goes out

An appeal can't be approved without an attestation checkbox — "submission" is a status change plus the letter, never automatic payer transmission.

Grounding, not assertion

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.

Bounded agent loop

A hard recursion limit fails a case to needs_review instead of looping indefinitely if the supervisor bounces between specialists.

Full audit trail

Every read/write touching a PriorAuthRequest, Document, or AppealCase writes an AuditLog row — actor, role, action, resource, IP, timestamp.

Role-based access

Providers see only their own requests; a provider probing another provider's request id gets a 404, never a 403.

PHI kept out of observability by construction

Raw clinical documentation structurally can't appear in a LangSmith trace — it's never part of traced graph state.

08

Lessons learned

💡 What actually held up
  • 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_retry fixed a real, live-only bug where a forced draft_appeal tool call intermittently omitted a required field.
What I'd do differently
  • 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.
🔁If I started this over: thread 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.
System
PriAuthra — Prior-Authorization Agent
Primary services
Claude API · LangGraph · Pinecone
Status
Live end-to-end, not yet on staging — BAAs pending
Type
Healthcare AI · agentic orchestration

Interested in the architecture behind this or another project?