n4nAI

How AI agents handle medical claims denial appeals

A practical how-to for engineers building AI agents claims denial appeals: ingest denials, retrieve policy, draft appeals, validate, and track outcomes.

n4n Team3 min read689 words

Audio narration

Coming soon — every post will get a voice note here.

Denied medical claims cost U.S. providers billions annually, and manual appeal writing is slow. AI agents claims denial appeals can cut turnaround from days to minutes, but only if you treat the agent as a constrained document pipeline rather than a chatbot. Below is the architecture we run in production for a revenue-cycle client, with code you can adapt.

Step 1: Ingest and normalize denial data

Most denials arrive as X12 277UA transactions or scanned EOBs. Parse the structured flavor first; PDF extraction should be a fallback only. You need the claim ID, payer ID, CARC (claim adjustment reason code), and optional RARC.

from dataclasses import dataclass

@dataclass
class Denial:
    claim_id: str
    payer_id: str
    carc_code: str
    rarc_code: str | None
    billed_amount: float
    service_date: str

def parse_277(denial_json: dict) -> Denial:
    # Assumes a pre-parsed X12 277UA JSON shape from your EDI translator
    status = denial_json["status"]
    return Denial(
        claim_id=denial_json["claim"]["id"],
        payer_id=denial_json["payer"]["id"],
        carc_code=status["carc"][0],
        rarc_code=status.get("rarc", [None])[0],
        billed_amount=float(denial_json["claim"]["amount"]),
        service_date=denial_json["service"]["date"],
    )

CARC codes are standardized by CMS. Map them to internal denial categories so downstream logic stays stable even when payers relabel things. If you only have a PDF, use a dedicated EDI parser downstream—do not let the LLM hallucinate the CARC from OCR noise.

Step 2: Retrieve payer policy and contract terms

Appeals fail when they cite the wrong clause. Store payer policies in a queryable store keyed by payer_id and CARC. For a few hundred payers, SQLite with exact matching is enough; for thousands, use a vector index.

import sqlite3

def get_payer_policy(payer_id: str, carc_code: str) -> str:
    conn = sqlite3.connect("policies.db")
    cur = conn.cursor()
    cur.execute(
        "SELECT text FROM policy_clauses WHERE payer_id=? AND carc=? ORDER BY rank LIMIT 3",
        (payer_id, carc_code),
    )
    rows = cur.fetchall()
    conn.close()
    return "\n".join(r[0] for r in rows)

If you prefer semantic search, embed the CARC description and policy clauses with a local model; cosine similarity beats exact match when payers rephrase. But keep the lookup under 200ms—appeals are batch jobs, not real-time. Cache these results with a 24-hour TTL; policy text changes quarterly.

Step 3: Configure the agent’s reasoning loop

The agent needs tools, not a stuffed prompt. We route completion calls through n4n.ai, a single OpenAI-compatible endpoint that addresses 240+ models and automatically falls back when a provider is rate-limited. This lets us swap models per CARC complexity without rewriting client code.

from openai import OpenAI

client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY")

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_payer_policy",
            "description": "Retrieve payer policy text for a CARC code",
            "parameters": {
                "type": "object",
                "properties": {
                    "payer_id": {"type": "string"},
                    "carc_code": {"type": "string"},
                },
                "required": ["payer_id", "carc_code"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_claim_details",
            "description": "Fetch original claim lines and diagnoses",
            "parameters": {
                "type": "object",
                "properties": {"claim_id": {"type": "string"}},
                "required": ["claim_id"],
            },
        },
    },
]

system_prompt = """You are an appeals writer for healthcare claims.
Use the provided tools to gather policy and claim context.
Output a structured appeal citing exact policy text. Never invent policy.
If data is missing, list gaps in missing_info."""

The tool-calling loop

import json

messages = [{"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Denial: {denial}"}]
for _ in range(5):
    resp = client.chat.completions.create(
        model="openai/gpt-4o-mini",
        messages=messages,
        tools=tools,
    )
    msg = resp.choices[0].message
    if not msg.tool_calls:
        break
    messages.append(msg)
    for call in msg.tool_calls:
        if call.function.name == "get_payer_policy":
            args = json.loads(call.function.arguments)
            result = get_payer_policy(args["payer_id"], args["carc_code"])
        elif call.function.name == "get_claim_details":
            args = json.loads(call.function.arguments)
            result = get_claim_details(args["claim_id"])
        messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

Cap iterations at five to avoid runaway costs. The goal of AI agents claims denial appeals is to convert a denial code into a reimbursable event without a human bottleneck, not to hold a conversation.

Step 4: Generate the appeal narrative

Force structured output. Define a schema and use JSON mode. The body should be plain text with numbered citations.

appeal_schema = {
    "type": "object",
    "properties": {
        "subject": {"type": "string"},
        "body": {"type": "string"},
        "cited_policies": {"type": "array", "items": {"type": "string"}},
        "missing_info": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["subject", "body", "cited_policies"],
}

In the final assistant call, pass response_format={"type": "json_object"} and instruct the model to conform. Keep the tone formal; payers reject appeals that read like marketing. Example subject: “Appeal of Claim {claim_id} – CARC {carc_code} – Medical Necessity”.

Step 5: Validate against regulatory rules

Pydantic catches malformed output before it leaves your network.

from pydantic import BaseModel, ValidationError

class Appeal(BaseModel):
    subject: str
    body: str
    cited_policies: list[str]
    missing_info: list[str] = []

def validate_appeal(data: dict) -> Appeal:
    return Appeal(**data)

Add custom checks: body must contain the claim_id, length under 1500 words, and at least one cited policy. If validation fails, re-prompt with the error message attached. Do not auto-submit when missing_info is non-empty—route to a human queue.

Step 6: Submit and track

Submission targets vary: some payers accept EDI 278 via clearinghouse, others require portal uploads or fax. Wrap them behind one internal endpoint.

import requests

def submit_appeal(appeal: Appeal, denial: Denial) -> str:
    payload = {
        "claim_id": denial.claim_id,
        "payer_id": denial.payer_id,
        "appeal_text": appeal.body,
        "attachments": appeal.cited_policies,
    }
    resp = requests.post("https://internal.rcm/submissions", json=payload, timeout=10)
    resp.raise_for_status()
    return resp.json()["submission_id"]

Persist the submission_id with the denial record. The clearinghouse will return a confirmation or a rejection code; map both back to the denial row.

Step 7: Verify success

Success is not “model returned text”. It is: (1) submission accepted by your gateway, (2) payer ACK received (X12 277), (3) claim status moves from denied to pending or paid (X12 835).

def verify_submission(submission_id: str) -> bool:
    status = requests.get(f"https://internal.rcm/submissions/{submission_id}").json()
    return status["state"] in ("accepted", "pending_adjudication")

# Poll daily for 30 days
for day in range(30):
    if verify_submission(sub_id):
        print(f"Day {day}: appeal accepted, awaiting adjudication")
    # else check payer 277/835 feed

Run this as a cron job. Alert if a submission never reaches accepted within 48 hours—that indicates a mapping bug in Step 1 or a blocked clearinghouse. End-to-end verification means you can trace a denial JSON to a payer payment file; anything less is guesswork.

Notes on compliance and cost

AI agents claims denial appeals handle PHI. Use a gateway that supports per-token usage metering and honors client routing directives so you can pin to a BAA-covered model. Redact free-text clinical notes before sending to any third-party inference unless the provider is explicitly covered.

Log every model input and output for reproducibility. Keep a hash of the denial JSON and the exact model slug from the gateway. The pipeline above is deliberately boring: no autonomous exploration, no multi-agent debate. For a task with legal and financial weight, deterministic steps with an LLM acting as a constrained writer is the only design that survives an audit.

Tagshealthcare-ai-agentsclaims-processingappealshealth-tech

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ai agents in healthcare operations posts →