Upload an insurance document, and have an agent read it, remember what it learned, and act on it.
Today the repo contains the two pieces that exist: the intake endpoint (upload → PDF/photo → disk) and a memory client for XTrace. The agent that joins them is not built yet.
~15% of medical claims are denied. Over half of appealed denials are eventually overturned — but 66% are never appealed at all, because appealing costs more staff labor than a small clinic has. Payers profit from that gap.
There is no general appeal strategy. Overturn rates swing wildly by procedure and by denial reason. In the California DMHC determination record, within musculoskeletal claims alone, pain-management denials are overturned 71.7% of the time while orthopedic-procedure denials are overturned 27.7% — and cutting the same claims by denial reason moves it again, 46.3% for medical necessity against 27.5% for experimental/investigational. Winning is procedure-specific procedural knowledge: what a reviewer needs to see before reversing a pain-management denial on medical necessity, versus what it takes to overcome an experimental/investigational exclusion. Today that knowledge lives in the head of one experienced billing manager, and it walks out the door when she quits.
InsuranceMaxx turns that tribal knowledge into machine memory. Every appeal outcome — win or loss — rewrites the agent's playbook for that payer. The agent doesn't just know more over time; it gets measurably better at the job.
Memory is scoped so that adjudication policy propagates across clinics while patient-linked content never leaves the clinic that owns it. Clinic A's win on a pain-management denial teaches Clinic B's agent tomorrow, because "a nerve block denied as not medically necessary reverses when the record documents failed conservative therapy and a specific functional deficit" is a rule about how claims of that shape get adjudicated, not a fact about a person. The denial letter, the chart, the patient — those stay put.
This is the moat and the compliance story in one sentence: the tenth clinic to join is more valuable than the first, and no patient data is what makes it valuable.
A model that writes a beautiful appeal letter from scratch every time is a letter mill. The value is in the compounding: recall what worked before acting, capture what the human corrected, ingest the outcome, revise beliefs when a strategy stops winning. The tenth appeal against a given payer should not look like the first.
Two numbers, instrumented from the first commit:
- Overturn rate — rolling, across the run sequence
- Human edit distance — how much a reviewer had to change the draft, trending toward zero
If those two aren't moving, the project doesn't work, however good the letters look.
Build status: only Beat 1's intake step exists today (drag a file onto the
platform → POST /api/upload → file in src/intake/inbox/). Everything below
is the target.
Beat 1 — one case, end to end. Upload a denial letter. The agent recalls (recalled memories shown on screen, labeled by type), triages with an expected-value number against the ~$44 cost to fight, and drafts a cited appeal. The reviewer edits two lines; the diff is captured as a lesson.
Beat 2 — the money shot. Run 1 versus run 30 on the same denial type. Run 1 writes a generic appeal and loses. Run 30 cites the exact criteria this payer requires and wins. Between them: the playbook that built itself, and the two metric curves.
Beat 3 — the moat. Clinic A's win posts to shared memory as a policy rule. Clinic B's agent, seeing that denial type for the first time, already knows the play. The patient episode stays in Clinic A.
Beat 4 — the close. A new claim, pre-submission: "This will be denied. Add this documentation." Denials prevented, not just appeals won.
Stated in the demo, not buried here:
- Data provenance. Cases and outcomes come from California DMHC Independent Medical Review determinations — real denials, real payer reasoning, real overturn/uphold outcomes, published by a state regulator. Denial letters are rendered from those records using real published payer policy language. Synthetic patients, real rules, real outcomes. Train/holdout split by date.
- No payer is named. DMHC publishes these determinations payer-anonymized, so the agent learns procedure-category and denial-reason playbooks rather than payer-specific ones. We name no payer we cannot substantiate.
- No PHI. Nothing in this system has touched protected health information.
- The adjudicator is simulated and knowledge-isolated from the drafting agent — they share no prompts, memory, or context. Without that separation the improvement curve would be circular and meaningless.
- What we'd measure with a design partner: true overturn rate against live payers, appeal turnaround time, and recovered revenue per clinic-month.
src/intake/ intake.py + inbox/ the FastAPI receiver (runtime)
src/memory/ xtrace_client.py XTrace Memory client, requests-only (runtime)
scripts/ spikes and one-offs not on the runtime path
data/ IMR CSV lands here gitignored except .gitkeep
docs/ SPIKE.md XTrace findings + raw spike output
Python is the only runtime. scripts/try_xtrace.mjs is kept purely as
reference for how the Node SDK behaves; nothing in the agent path shells out to
Node, and node_modules/ was deliberately not carried over.
One .env at the repo root, gitignored. .env.example lists the keys with
placeholder values.
| Key | Used by | Notes |
|---|---|---|
XTRACEAI_API_KEY |
src/memory/ |
Not XTRACE_API_KEY. Values start mmk_ |
ANTHROPIC_API_KEY |
the agent (unbuilt) | placeholder in .env.example only |
The XTRACEAI_API_KEY name is load-bearing — load_client() checks it first
and only falls back to XTRACE_API_KEY. Don't rename it.
A FastAPI service that receives uploaded documents and drops them in
src/intake/inbox/ for downstream processing.
Documents are uploaded through the platform itself — drag a file onto the page or use the picker. PDFs are the normal case; JPEG/PNG/HEIC are also accepted so a photo of a letter degrades gracefully instead of failing hard. PDFs are stored exactly as received — never converted or re-encoded. The one exception is HEIC, which is normalized to JPEG at this boundary because Claude cannot read HEIC (see below).
| Method | Path | Body | Returns |
|---|---|---|---|
POST |
/api/upload |
the file's raw bytes | {"case_id", "filename", "original_name", "bytes", "kind", "media_type", "converted_from"} |
GET |
/health |
— | {"ok": true} |
Raw body rather than multipart/form-data: multipart would pull in
python-multipart for no gain, since one file per request needs no part-boundary
parsing. The browser sends the File object straight as the body.
case_id is an 8-character uuid; the file lands at inbox/<case_id><ext>,
where the extension comes from sniffing the payload's magic bytes rather than
trusting anything the client claims. An optional X-Filename header is recorded
in the log line only — a file named .pdf over a zip's bytes is still
rejected as a zip. Uploads are capped at 25 MB.
kind tells downstream which Claude content block to use:
| Magic bytes | Stored as | kind |
media_type |
Claude block |
|---|---|---|---|---|
%PDF |
.pdf |
document |
application/pdf |
document block |
\xff\xd8\xff |
.jpg |
image |
image/jpeg |
image block |
\x89PNG\r\n\x1a\n |
.png |
image |
image/png |
image block |
ftyp + HEIF brand |
.jpg ↻ |
image |
image/jpeg |
image block |
↻ HEIC is converted to JPEG at intake via macOS's built-in sips
(formatOptions best — these are documents, so legibility beats file size).
Claude's image blocks accept image/jpeg, image/png, image/gif, and
image/webp but not image/heic, so an unconverted HEIC would fail at the
model call instead of at the boundary. Converting here keeps the failure early
and keeps the agent code free of format branching — downstream only ever sees
types Claude can read.
Conversions are visible two ways: the response carries
"converted_from": "image/heic" (null otherwise), and the log line ends with
— CONVERTED from image/heic. If sips fails, intake returns 400 rather than
storing an unreadable file.
PDFs are never touched. Anything unsupported returns 400 with a message naming what actually arrived.
Every failure returns HTTP 400 with a plain-language {"error": "..."} naming
what actually arrived — the UI renders that string verbatim, so the response
body is the error surface. Covered: empty body, oversize payload, and
unsupported file types (identified as zip, WebP, GIF, video, or a byte preview).
From the repo root:
./scripts/start_platform.shThat starts the server and opens the platform. To run it by hand:
uv run uvicorn intake:app --app-dir src/intake --host 127.0.0.1 --port 8000--app-dir puts src/intake/ on the import path, so inbox/ resolves next to
intake.py regardless of where you invoke it from. It binds to loopback: the
platform holds denial letters and has no auth, so it should not be reachable
from other machines.
src/memory/xtrace_client.py is a dependency-light XTrace Memory client built
on requests — ingest, search, get/delete/list, groups, and a client-side
recall() fan-out. Findings from the API spike, including the endpoints that
turned out not to exist, are in docs/SPIKE.md, with raw
output in docs/spike_results.json.
Re-run the spike with:
uv run python scripts/xtrace_spike.pyNothing in the intake path imports this yet — wiring the two together is the agent work, which is not started.
scripts/build_corpus.py renders the DMHC determinations into appeal cases.
Deterministic templates, no model call anywhere in the build.
uv run python scripts/profile_imr.py # column/value profile of the raw CSV
uv run python scripts/build_corpus.py # → data/cases/{train,holdout}/*.json200 cases, musculoskeletal, across three treatment categories, split by
ReportYear so memory cannot peek at outcomes it will be evaluated on:
| Split | n | Years | Overturned |
|---|---|---|---|
| train | 141 | 2019–2023 | 63.1% |
| holdout | 59 | 2024–2025 | 67.8% |
The playbook key is (TreatmentCategory, Type). Its overturn spread across the
corpus runs from 18.8% (Orthopedic Proc | Experimental/Investigational) to
90.2% (Pain Management | Medical Necessity) — that spread is the signal, so
sampling is proportional and outcome balance is deliberately not normalized.
Each case JSON carries three parts:
letter— the rendered denial notice, the only thing an agent should seestructured— requested service, diagnosis, treatment category, denial reason, playbook keyground_truth— the real determination plus the IMR reviewer's rationale, preserved verbatim and unsummarized, because that text is what procedural memory learns from
provenance on every case records that no payer is named, that no CPT/CARC/RARC
or claim identifiers were synthesized, and that only ReportYear is real (the
month on the letter is deterministic filler).
Letters are rendered solely from the request, diagnosis, and denial reason — never from the reviewer's determination. The build asserts zero letters contain outcome language; it currently reports 0 of 200.
data/ is gitignored, so the corpus regenerates from the CSV rather than being
committed.
Three separated functions, one cycle per denial:
recall(treatment_category, denial_type) → memories from XTrace (mode=retrieve)
act(denial_event, memories) → triage decision + appeal draft
save(outcome, missed_criteria) → ingest the lesson back to XTrace
A cycle runs: recall → act → adjudicate → simulate reviewer edit → save. Cycles are concentrated in a single playbook cell so memory compounds, which is the run-1-versus-run-30 comparison the demo turns on.
The drafting agent never sees the IMR reviewer's Findings text or the criteria derived from it. It learns which criteria matter only from adjudicator rejection feedback. Without that separation the improvement curve would be circular: an agent handed the answer key would look like it was learning while measuring nothing.
This is enforced in code, not by convention — see src/agent/contract.py:
DenialEventis the only case representation the drafting side accepts. It is built byload_denial_event(), which readsletterandstructuredand never opensground_truth. No field on it can carry the rationale.AnswerKeyis loaded by a separate function used only by the criteria extractor and the adjudicator.- Every drafting prompt passes through
guard_prompt(), which raisesIsolationBreachon contact with answer-key material. It compares six-word content shingles rather than exact substrings, so a paraphrase or a reformatted copy is caught too.
Breaking isolation therefore requires deleting code, not forgetting a rule.
tests/test_isolation.py asserts it both ways: the guard never fires on a real
drafting prompt across every train case, and it does fire on a planted
rationale slice. A guard that cannot fail proves nothing, so the positive
control is part of the suite.
The adjudicator never looks up the real outcome. Overturn rates run ~90% in the largest cell, so deciding by outcome-lookup would make the agent's work irrelevant and flatten the curve by construction. The decision is a function of the appeal text against the criteria: the model marks each criterion addressed or missed, and coverage against a threshold produces overturn/uphold in code, never as a model judgment. The real IMR outcome is carried alongside for reporting only.
Two facts verified against the live XTrace API rather than assumed:
- Conversation-turn framing lets XTrace extract memories itself. The type that
comes back is
fact, notprocedural. retrievereturns in ~650 ms with discriminative scores (0.699, 0.648);composetakes ~1460 ms and collapses them to 1.0 / 0.999. The loop usesretrievewherever ranking matters.
Memory is scoped per playbook cell and per run, so each run starts from an empty playbook and runs cannot contaminate each other.
uv run python tests/test_isolation.py # prove the boundary (offline, no API)
uv run python src/agent/criteria.py # cache criteria (adjudicator side)
uv run python src/agent/runner.py --cycles 30 --run-id treat30
uv run python src/agent/runner.py --cycles 30 --run-id ctrl30 --no-memory
uv run python scripts/build_dashboard.py --run treat30 # → runs/treat30.htmlOffline only. Recall is ~650 ms and model calls stack on top, so cycles are
never run live during a demo — the dashboard reads runs/*.json.
Memory is scoped by run_id and persists in XTrace across restarts. Reusing a
run_id inherits that run's playbook, so a genuine cold start needs a new one.
A within-run curve cannot prove memory works when coverage starts near the ceiling — a flat line then means "already good", not "learning nothing". So the runner has a control arm:
| Arm | Command | Recall | Save |
|---|---|---|---|
| treatment | --run-id treat30 |
yes | yes |
| control | --run-id ctrl30 --no-memory |
no | no |
Same cases, same order, same models. The gap between the arms is memory's contribution, and it is legible even when the within-run curve is flat.
scripts/build_dashboard.py renders a run into one self-contained HTML file —
no server, no build step, no network. It shows the three curves (criteria
coverage, rolling overturn rate, human edit distance), each with a
first-third-versus-last-third delta and an explicit improving / flat /
degrading verdict, then every cycle as an expandable row: the memories
recalled, criteria addressed versus missed, the adjudicator's reasoning, and
the appeal draft.
| Role | Model | Why |
|---|---|---|
| Drafting | claude-opus-5 |
The one call where quality decides whether the curve moves |
| Criteria extraction | claude-haiku-4-5 |
Structured extraction over ~200 cases, no judgment |
| Adjudication | claude-haiku-4-5 |
Structured yes/no per criterion, runs every cycle |
| Reviewer-edit sim | claude-haiku-4-5 |
Only needs a plausible edit for the distance metric |
- macOS-only intake. HEIC conversion shells out to
sips, which is a system binary. On Linux this needs a different converter. - Single user, no auth. Nothing on the platform is authenticated, so it binds to loopback and is not reachable from other machines. Fine for one operator on one laptop; not fine beyond that.
inbox/is a queue, not storage. Files accumulate and are never rotated or cleaned up.- Uploads are capped at 25 MB and read fully into memory, which is fine at one file per request and would not be under real concurrency.
data/is empty by design. The IMR CSV is expected to land there; it is gitignored so the dataset never enters git history.