Claude Code writes FHIR and HL7v2 mapping code fast. It does not get to decide when that code is done.
I've built HL7v2 and FHIR interfaces on Mirth, RabbitMQ, Snowflake and GCP, and CDS Hooks services for MedHook and eSpiral. One thing holds across all of them. Interface code is only correct once real-shaped messages go through it and come out right. The agent will report "finished" either way.
My rule is that a verification loop decides done. The agent runs four gates on itself before a human looks: a validator, a replay harness, a terminology check and a PHI tripwire. All four ship as a Skill, so every repo gets the same ones.
Same rule as my support loop, different gates
I already run an agent under this rule. My L2 support loop at MedScrub writes a failing test first and fixes the bug in a throwaway worktree. Then it opens a PR and never merges. It pings me only when the real PR gates are green. The teams actually shipping coding agents converged on the same pattern. The agent proposes, and a gate decides.
Interface code needs the same rule with different gates. A unit test that checks PID-5 lands in Patient.name proves almost nothing. The bugs come from messages nobody put in the test folder.
So the gates test messages and resources, and the agent runs them itself.
How to run the FHIR Validator CLI against US Core
The first gate is the official HL7 FHIR Validator. Run it against US Core and whatever IG profiles your interface promises. It ships as validator_cli.jar and runs from the command line:
java -jar validator_cli.jar out/fhir/*.json \
-version 4.0.1 \
-ig hl7.fhir.us.core
-ig ./ig/package.tgz \
-output out/validation.json
Pin the US Core version your certification or contract targets. Check the canonical page before you pin it. Don't let the agent pick "latest."
The gate parses the output and fails on any issue with error severity.
Block one flag: -tx n/a. It turns off terminology checking. An agent chasing a faster or quieter run could reach for it, and then you've thrown away the most useful part of the check.
Know what the validator can't see. It tells you the resource is a legal US Core Observation. It does not tell you it's the right Observation. If a mapping put a potassium result on the sodium code, the output would be valid and wrong. The next gate catches that.
Testing HL7v2 to FHIR mappings with replayed messages and golden files
Keep a folder of synthetic v2 messages. Each one has a hand-reviewed golden FHIR Bundle next to it. The harness pushes every message through the real transform. In a lot of shops that means the actual Mirth channel, not a copy of its logic. Then it diffs the output against the golden file. It ignores fields that change on every run, like id and meta.lastUpdated. Any other difference fails the gate.
A fixture can be tiny. This one packs three edge cases into six lines:
MSH|^~\&|LAB|SYNTH|EHR|SYNTH|20250101083000||ORU^R01|FX-0001|P|2.5.1
PID|1||TEST123^^^SYNTH||Fixture^Pat
OBR|1|||BMP
OBX|1|NM|2951-2^Sodium^LN||140|mmol/L
OBX|2|NM|2823-3^Potassium^LN||4.1|mmol/L
OBX|3|TX|COMMENT^Lab comment^L||Tubes A\T\B hemolyzed
Its golden Bundle expects three Observations, in OBX order. The comment comes out with the real separator character, not a literal \T\. The comment code carries a local system, not LOINC. The timestamp in MSH-7 has no offset, so the golden file pins whatever timezone policy you chose.
A human reviews each golden file once. After that, make it mechanically hard for the agent to change them:
- Put
golden/ under a CODEOWNERS rule.
- Add a CI check that rejects any commit touching both
golden/ and mapping code.
Updating a golden file should be its own visible commit with a reason. It deserves harder review than the code. A bad golden file turns the whole harness into a rubber stamp.
Cover at least these:
- Repeating OBX. A lab panel with twelve OBX segments should produce twelve Observations, in order. Mapping code that grabs the first OBX passes every single-result test you'll ever write.
- Z-segments. Segments starting with Z are site-defined. Your mapping should either handle each one or drop it on purpose. Silently ignoring one is a decision nobody made.
- Escape sequences. Cover the separators (
\F\, \S\, \T\, \R\, \E\), highlighting (\H\, \N\) and hex (\Xhh\).
Each one should decode to what it stands for. None should land as literal backslash text in a clinician-facing note.
- Timezones. A v2 timestamp can arrive with no offset, and your FHIR output shouldn't guess. Pick a policy, such as "apply the sending facility's configured zone, or reject and log." Then write a fixture that proves it.
The mapping itself is a design-time job, not something a model does on live traffic. I covered that split in HL7v2 to FHIR Conversion With an LLM. This harness keeps the mapping honest after the agent touches it. The v2-to-FHIR IG publishes segment and datatype mappings, like OBX to Observation. Use it to seed golden files, not to replace them.
Every production defect becomes a fixture: a synthetic message that reproduces it, and a golden file that shows the fix. After that, the same failure can't come back without turning the build red.
Validate terminology codes against a terminology server
The third gate checks that every code in the output resolves. Run each Coding against a terminology server with $validate-code. The public server is tx.fhir.org. Send it synthetic data only. Nothing real ever goes to a public server.
Treat a code that doesn't resolve as a failure, not a warning. Warnings are easy to scroll past, and this class of bug passes structural validation:
- a local lab code passed straight through with a LOINC system URI on it
- a typo in a SNOMED code
- a code that was never in the value set
In each case the resource validates, but the receiving system can't use it.
If your feed has legitimate local codes, map them explicitly or tag them with a local system, like the comment code in the fixture above. Then the gate can tell "we meant this" from "we broke this."
CDS Hooks responses can be valid and wrong too
The same gap shows up in CDS Hooks services like the ones behind MedHook and eSpiral. A response can pass a schema check and still fire for the wrong context or suggest the wrong code. Use the same harness: synthetic hook requests paired with hand-reviewed golden responses, including requests where the right answer is silence. Getting the hook to fire inside an EHR at all is its own fight, covered in CDS Hooks: What Nobody Tells You Before You Build.
Claude Code in healthcare: hooks that block PHI and production credentials
The fourth gate fences in the agent. Claude Code PreToolUse hooks run after the agent builds a tool call and before it executes. The hook gets the call as JSON on stdin. If it exits with code 2, the call is blocked, and the hook's stderr goes back to the agent as the reason. Event names and output fields have shifted between releases, so check the current hooks docs before you copy this.
import json, re, sys
BLOCKED = [r"/mnt/phi/", r"message_archive/", r"\.env\.prod",
r"prod[-_]creds", r"-tx\s+n/a"]
call = json.load(sys.stdin)
args = call.get("tool_input", {})
target = " ".join(str(args.get(k, "")) for k in ("file_path"
Add any other file tools your version exposes to the matcher. The hook blocks the real message archive, production env files and credentials, and the -tx n/a shortcut from earlier. Its stderr message points the agent at the synthetic fixtures, so a block redirects it instead of stalling it.
This is a tripwire, not a wall. A determined enough script can walk around a regex on a shell command. The real boundary is that the dev machine has no PHI or production credentials in the first place. The hook is there for the honest mistake, like an agent deciding to "just check a real message" while debugging a mapping.
Whether Claude Code belongs near PHI at all is a separate question. It fits under Anthropic's BAA only in narrow setups, and even then that doesn't make your app compliant. More in Is Claude Code HIPAA Compliant?
Package the checks as a Claude Code Skill
The last piece is distribution. Checks that live in one engineer's head, or one repo's Makefile, drift. A Skill is a folder with a SKILL.md file plus any scripts it needs. The file starts with YAML frontmatter that has at least a name and a description. Project Skills live in .claude/skills/. Claude loads a Skill when its description matches the task.
Call it interface-verify. Give it a description like "Verify any change to HL7v2, FHIR or CDS Hooks interface code. Use before calling an interface change done." The body is five steps:
- Run the replay script. Every fixture must match its golden output.
- Run the validator script. Zero error-severity issues against pinned US Core and IG.
- Run the terminology script. Every Coding resolves or carries a declared local system.
- Never edit
golden/ in the same commit as mapping code.
- Report which step failed and why. Don't report done until all pass.
Copy the same Skill and the same hook into every interface repo. One feed shouldn't get a looser definition of done than another just because a different person set it up.
How to measure a coding agent's verification pass rate
Track two numbers.
The first is the share of agent changes that pass the full loop before a human first reviews them. Trend it week over week. If most changes fail the loop before you see them, you're back to being the verifier, which is the job you were trying to hand off. Tighten the Skill, add fixtures, or narrow what you give the agent. As the number climbs, review gets shorter, because you're reading changes that already cleared the boring failures.
The second is fixtures added from production defects. If production keeps finding bugs and the fixture folder isn't growing, the loop isn't learning. If the folder grows and the same failures stop coming back, it is.