HL7v2 to FHIR Conversion With an LLM: Mapping, Not Messages
Use an LLM for HL7v2 to FHIR mapping at design time, never on live messages. Real bugs, a diff-and-validate loop, and how to catch feed drift.
Mostly about building products, healthcare tech, and lessons learned along the way.
Use an LLM for HL7v2 to FHIR mapping at design time, never on live messages. Real bugs, a diff-and-validate loop, and how to catch feed drift.
Yes, use an LLM on HL7v2 to FHIR work. Don't let it touch the messages. The model belongs at design time. It drafts mappings from de-identified samples, writes tests from synthetic messages, and points at what broke. Every live ADT and ORU should go through a deterministic converter that does the same thing to the same bytes every time. I hold that view because the HL7 bugs that have hurt us are boring ones. A model in the message path would make boring bugs random.
One bug keeps coming back. HL7v2 uses a caret to separate components, a tilde to separate repetitions, and an ampersand to separate sub-components. Once a parser flattens a field into an object, you can't tell a^b~c from a^b&c. We hit that in 2023 on Retrohook, our first HL7 product, and it cost us a run of mapping hotfixes. We found it again in 2026, in review, before MedHook's first live feed. The converter read only the first component of the phone field, so a number sent in the common ^PRN^CP^^^area^local form came through empty. MedHook texts patients. With an empty phone number, the product can't do its one job.
In MedHook, the converter that handles live messages is plain TypeScript. The LLM shows up in three places:
T.The model proposes, a person approves, deterministic code runs.
Any one of these three is enough to rule it out.
It costs money per message. Every message becomes a paid network call on a feed that never stops. The bill grows with patient census, not with features.
It's nondeterministic. If our converter reads PID-13 wrong, it reads it wrong every time, and one fix covers every message. A model that usually gets it right hands you a bug you can't reproduce.
It sends PHI to the model. Every live message is a patient. Our mapper won't even send a raw sample. Streaming production traffic to a model means a different compliance conversation, and a worse one.
The deeper problem is that the hard cases in HL7 are ambiguous by construction. A timestamp with no offset doesn't say what zone it's in. You can tell a converter to refuse. A model will guess.
If you're new to the wire format, start with the HL7v2 basics. These are the cases that bit us.
Repeating fields. Here's a synthetic PID with two repetitions in PID-13:
PID|1||12345^^^MRN||DOE^JANE||19800101|F|||||^PRN^CP^^^312^5550142~^NET^Internet^jane@example.com
XTN puts the area code in component 6, the local number in component 7 and the email in component 4. Component 1 is empty. Read only the first component of the first repetition and Jane has no phone and no email. Our fix was to read every repetition from the raw segment text, not the parsed tree. Each XTN repetition in PID-13 and PID-14 now becomes its own ContactPoint.
// Sketch: split repetitions before components, from raw field text.
// Take delimiters from MSH-1/MSH-2, and handle escape sequences in a real version.
function xtnRepetitions(raw: string, rep = "~", comp = "^") {
return raw.split(rep).map((r) => {
const c = r.split(comp);
return { use: c[1], equipment: c[2], email: c[3], area: c[5], local: c[6]
Timestamps with no UTC offset. Plenty of feeds send 20261015093000 and mean local wall time. Read that as UTC and a 9:30 Chicago appointment becomes 4:30, so the reminder text goes out at the wrong time. FHIR won't let you dodge it either. An R4 dateTime that carries hours and minutes needs a time zone, so the converter has to pick one. Ours used to pick UTC, silently, for everything except scheduling messages. Now MedHook makes you name the sender's time zone. New channels fail the message rather than guess. The softer "warn" mode at least reports which fields it read as UTC, PV1-44 for example.
OBX-5 value types. OBX-2 tells you what OBX-5 holds, and senders don't always agree with themselves. A non-numeric value in an NM result used to come out of our converter as NaN. Now it's kept as text. A NaN in a lab result is the kind of bug nobody notices until a clinician does.
Z-segments. Every site has its own. Our converter dropped them until recently. Now it keeps them as a raw extension, so nothing vanishes without a trace. Microsoft's HL7v2 templates map a fixed list of standard segments, and none of them are Z-segments. Fields they can't map directly go into extensions. Whatever tool you use, a site's custom data means custom work for you.
The trip back out. FHIR to v2 has its own trap. MedScrub writes clinical notes back to an EHR as an HL7 v2.4 ORU. LLM-written text is full of em dashes, arrows and "≥". An ISO-8859-1 HL7 transport rejects or garbles those, and a caret in "cm^2" quietly splits the value into two components. We now map that Unicode punctuation and escape every note before it's sent. If your notes come from a model, your outbound channel needs a sanitizer.
We wrote our own. You probably shouldn't start there. The serious options:
Microsoft's FAQ says two things plainly. First, a successful conversion doesn't guarantee a bundle that's valid against your profile. Validation is a separate $validate step. Second, $convert-data is one piece of a pipeline, not the pipeline. Ingestion, validation, deduplication and loading are still yours. Their sample batch pipeline doesn't handle message sequencing either, and an A08 that lands before its A01 is a real problem.
We had Claude build a converter that turns MedHook channels into Mirth channel XML. The first version came with a round-trip test: import, export, import again, and check that the connectors are identical. The test passed. An audit a week later found four bugs anyway:
MSH.9.1 == 'ADT^A01'. That can never match, because MSH-9.1 is just ADT.A test the model writes against its own output proves consistency, not correctness. It's the same lesson as LLM-as-judge evals. What fixed it was a CI job that loads every export into OIE 4.5.2, a Mirth fork. The job deploys each export, sends synthetic ADTs over MLLP, and checks each destination against what MedHook would have sent. 110 of 110 passed.
The pattern held in code review. The model is good at scaffolding tests from synthetic messages, at structure, and at sticking to the spec once you point it there. It's weak on HL7's edge cases. In one PR it got the SCH-11 duration and units wrong and left the UTC offset off MSH-7 and EVN-2. It also sent a second ADT^A01 when re-exporting an in-progress encounter, where an A08 belongs. Review caught all of it. The model didn't catch any of it on its own. I haven't measured time saved, so I won't give you a number.
I'd put every mapping change through a diff-and-validate loop, whether a model or a person wrote it:
The diff carries the weight. It turns "the model changed the mapping" into "PID-13 now produces two ContactPoints where it produced zero." A reviewer can approve that.
The honest answer first: MedHook's HL7 channels are new enough that no sender has changed a feed on us yet. Our closest call was on the FHIR side, and the drift was our own doing. We widened the scopes on an EHR bulk export, but an old hard-coded resource-type filter kept telling the vendor not to send notes or labs. The sync pulled about 75,000 records with zero notes and zero lab reports. Nothing errored. We noticed because charts looked thin. The nightly logs showed every export still carried the old filter.
A parser wouldn't have caught that. A census would have caught it on day one. For each feed, keep a baseline of message types, segments, the fields that are usually populated, and volume by hour. Alert when something you expect stops arriving or something new shows up.
// Sketch, not shipped: compare today's feed census to a baseline.
type Census = { segs: Record<string, number>; filled: Record<string, number>; tsNoOffset: number };
function drift(base: Census, now: Census): string[] {
const out: string[] = [];
for (const s in base.segs) if (!now.segs[s]) out.push(
MedHook already alerts on silence, error rate, latency and specific field values, and new channels fail closed on missing time zones. Converter warnings get attached to each step's result, but nothing alerts on them yet. Structural drift alerting is the piece we haven't built. It's the gap I'd close before trusting any feed for the long haul.