What it does
MEDITECH sends one ADT feed. Three systems want it, each in its own shape. The route adt-out has three destinations, and each one has a transform:
| Destination | Transform | What it gets |
|---|---|---|
lis, MLLP | transforms/lis.toml: rules and a CSV code table | HL7 addressed to the LIS, the MRN copied to PID-2, a ZLB segment for emergency registrations, PV1-2 mapped to the LIS's own codes (a code the table doesn't have is an error), no NK1 |
patient-api, HTTP | transforms/patient-api.toml with a Liquid template | JSON, sent as application/json: the event, patient, visit and allergies |
research, HTTP | inline in gateway.toml | HL7 with the MRN and visit number replaced by salted SHA-256 hashes; name, address, phone and SSN cleared; year of birth only; no NK1 |
The queue keeps each event as MEDITECH sent it, and a transform runs at each delivery. So when the LIS's table is missing a code, that event waits in the LIS's dead letters only; once the table has the code, a requeue sends it transformed with the new entry. The portal and the registry are never held up. You need the gateway image 0.6.0 or later.
The sample is in 13-one-message-three-shapes in the examples repository.
Diagrams

architecture.mmd
data-flow.mmd
sequence.mmdThe configuration
13-one-message-three-shapes/gateway.toml — one route, three destinations, each with a transform
# Sample 13: one ADT feed from MEDITECH, and each system gets it in its own shape (HL7 Edge Gateway 0.6+).
# lis the lab system, over MLLP: HL7 with its own codes transforms/lis.toml
# patient-api a patient portal's REST API: JSON from a Liquid template transforms/patient-api.toml
# research a research registry: HL7, de-identified inline, below
# Secrets come from .env.
[admin]
listen = "0.0.0.0:8080"
token = "${HL7EDGE_ADMIN_TOKEN}"
allow_payload_view = true # the console's message viewer, with "as received" and "as sent"
[store]
backend = "embedded"
path = "/var/lib/hl7-edge-gateway"
[[route]]
name = "adt-out"
mode = "store_and_forward"
source = { type = "mllp", listen = "0.0.0.0:7890" }
store_and_forward = { on_reject = "dead_letter", retry = { initial = "1s", max = "5s" } }
[[route.destinations]]
name = "lis"
type = "mllp"
address = "lis:6661"
transform = "transforms/lis.toml"
[[route.destinations]]
name = "patient-api"
type = "http"
url = "http://systems:8000/patients"
headers = { Authorization = "Bearer ${PORTAL_API_TOKEN}" }
transform = "transforms/patient-api.toml" # sent as application/json
[[route.destinations]]
name = "research"
type = "http"
url = "http://systems:8000/research"
transform = { step = [
{ hash = { fields = ["PID-3.1", "PV1-19"], salt = "${RESEARCH_SALT}" } }, # the same patient, the same hash
{ clear = ["PID-5", "PID-11", "PID-13", "PID-19"] }, # name, address, phone, SSN
{ date = { field = "PID-7", to = "%Y" } }, # year of birth only
{ delete = { segment = "NK1" } }, # next of kin
] }
13-one-message-three-shapes/transforms/lis.toml — the LIS's rules, run in order
# What the lab system (LIS) receives: HL7 still, addressed to it, with its own patient class codes, the MRN
# where it looks for it, an ED flag, and no next of kin. Steps run in order, each on the message as the steps
# before it left it. Try it without sending anything:
# docker compose run --rm tool transform -t /sample/transforms/lis.toml --diff /messages/adt-a04-emergency.hl7
[[step]]
set = { "MSH-5" = "LIS", "MSH-6" = "LAB" }
# The LIS reads the MRN from PID-2.
[[step]]
copy = { from = "PID-3[1].1", to = "PID-2" }
# Emergency registrations: the LIS wants the ED flagged in a Z-segment. This step comes before the map
# below, so PV1-2 is still MEDITECH's "E" here.
[[step]]
when = { message_type = ["ADT^A04"], fields = { "PV1-2" = ["E"] } }
add = { segment = "ZLB", after = "PV1", fields = { "ZLB-1" = "ED", "ZLB-2" = "STAT" } }
# The LIS has its own patient classes. A code it doesn't have is an error, not a guess:
# that event goes to the LIS's dead letters until the table has it.
[[step]]
map = { field = "PV1-2", table = "patient_class", missing = "fail" }
[[step]]
delete = { segment = "NK1" }
[tables]
patient_class = "tables/patient-class.csv"
13-one-message-three-shapes/transforms/tables/patient-class.csv — MEDITECH's patient class, and the LIS's
code,value
# MEDITECH's PV1-2 patient class, and the LIS's code for it.
I,INPT
E,EMER
O,OUTPT
P,PREADM
R,RECUR
13-one-message-three-shapes/transforms/patient-api.toml
# What the patient portal's API receives: JSON, made by the Liquid template patient-api.liquid.
# docker compose run --rm tool transform -t /sample/transforms/patient-api.toml /messages/adt-a01-inpatient.hl7
[[step]]
template = "patient-api.liquid"
output = "json" # checked before it's sent, and sent as application/json
[tables.patient_class]
I = "inpatient"
E = "emergency"
O = "outpatient"
"*" = "other"
[tables.event]
"ADT^A01" = "admitted"
"ADT^A03" = "discharged"
"ADT^A04" = "registered"
"ADT^A08" = "updated"
"*" = "other"
13-one-message-three-shapes/transforms/patient-api.liquid — the portal's JSON
{%- assign allergies = msg | segments: "AL1" -%}
{%- assign room = msg | field: "PV1-3.2" -%}
{
"event": {{ message_type | lookup: "event" | json }},
"messageId": {{ control_id | json }},
"patient": {
"mrn": {{ msg | field: "PID-3[1].1" | json }},
"name": { "family": {{ msg | field: "PID-5.1" | json }}, "given": {{ msg | field: "PID-5.2" | json }} },
"birthDate": {{ msg | field: "PID-7" | iso_date | json }},
"sex": {{ msg | field: "PID-8" | json }}
},
"visit": {
"number": {{ msg | field: "PV1-19" | json }},
"class": {{ msg | field: "PV1-2" | lookup: "patient_class" | json }},
"location": {{ msg | field: "PV1-3.1" | append: " " | append: room | strip | json }},
"admitted": {{ msg | field: "PV1-44" | iso_date | json }}
},
"allergies": [{% for a in allergies %}{{ a | field: "AL1-3.2" | json }}{% unless forloop.last %}, {% endunless %}{% endfor %}],
"receivedAt": {{ received | json }}
}
Step by step
- 1Start it.
cd 13-one-message-three-shapes cp .env.example .env # add your license docker compose up -dIn the console at
http://localhost:8080, the routeadt-outhas a row for each destination, with its transform. - 2MEDITECH sends an inpatient admission and an emergency registration.
docker compose run --rm send --to gateway:7890 /messages/adt-a01-inpatient.hl7 /messages/adt-a04-emergency.hl7 docker compose logs --no-log-prefix lis lis: AA ADT-1302 lis: MSH|^~\&|MEDITECH|GENERAL_HOSPITAL|LIS|LAB|20261021091500||ADT^A04^ADT_A01|ADT-1302|T|2.5.1 lis: EVN|A04|20261021091500 lis: PID|1|200302|200302^^^GENERAL_HOSPITAL^MR||LINDQVIST^ERIK||19921102|M|||7 Mill St^^Lakeside^ON^K0K 1A0||(555)010-4455||||||999-01-2302 lis: PV1|1|EMER|ED^BAY3^1||||1234^HALE^SIMON|||MED|||||||||V5002|||||||||||||||||||||||||20261021091200 lis: ZLB|ED|STAT curl http://localhost:8000/received "system": "patient-api", "content_type": "application/json", "body": { "event": "admitted", "messageId": "ADT-1301", "patient": { "mrn": "200301", "name": { "family": "OKAFOR", "given": "AMARA" }, "birthDate": "1985-03-14", "sex": "F" }, "visit": { "number": "V5001", "class": "inpatient", "location": "4W 401", "admitted": "2026-10-21T08:25:00" }, "allergies": ["Penicillin", "Cashew nuts"], … } "system": "research", "body": [ …, "PID|1||28dbd8dba5969e19004e1bffaa1ba78598096b48ed73eb552c7710996e64f56c^^^GENERAL_HOSPITAL^MR||||1985|F|||||||||||", … ]The LIS gets
PV1|1|EMER|…(andINPTfor the admission), the MRN in PID-2, the ED flag, and no next of kin. The portal gets JSON. The registry gets HL7 whose MRN is a hash: the same patient always gets the same hash, so records still link up, but the MRN can't be read back.
Each destination shows its transform. This is after step 3: the LIS has one dead letter, the others have every event. - 3A patient class the LIS doesn't have.
docker compose run --rm send --to gateway:7890 /messages/adt-a08-boarder.hl7 1 4 ms MSA|AA|ADT-1303MEDITECH gets AA: the event is stored for all three. The portal (
"class": "other", from the template table's"*"entry) and the registry get it. The LIS's map hasmissing = "fail", so ADT-1303 goes to the LIS's dead letters, with the failing step as the reason. Select thelisrow, then Dead letters, and open it: As received shows MEDITECH's event, and As sent to lis shows why it can't be sent.
The dead letter holds the event as MEDITECH sent it. 
As sent to lis: the transform runs again, and says which step fails. - 4Add the code, restart, requeue.
echo "B,BOARDER" >> transforms/tables/patient-class.csv docker compose run --rm tool check -c /sample/gateway.toml docker compose restart gatewayRequeue ADT-1303 from the LIS's dead letters in the console. The LIS gets it with
PV1|1|BOARDER|…: the dead letter held the original event, and the transform ran again with the new table. - 5See what a transform does, without sending anything.
The
transformcommand runs a destination's transform on message files. With--diff, it shows the segments it changed:docker compose run --rm tool transform -c /sample/gateway.toml --destination lis --diff /messages/adt-a04-emergency.hl7 transform OK: lis.toml: 5 rules /messages/adt-a04-emergency.hl7 ADT-1302 ADT^A04 - MSH|^~\&|MEDITECH|GENERAL_HOSPITAL|HIS|GENERAL_HOSPITAL|20261021091500||ADT^A04^ADT_A01|ADT-1302|T|2.5.1 + MSH|^~\&|MEDITECH|GENERAL_HOSPITAL|LIS|LAB|20261021091500||ADT^A04^ADT_A01|ADT-1302|T|2.5.1 EVN|A04|20261021091500 - PID|1||200302^^^GENERAL_HOSPITAL^MR||LINDQVIST^ERIK||19921102|M|||7 Mill St^^Lakeside^ON^K0K 1A0||(555)010-4455||||||999-01-2302 + PID|1|200302|200302^^^GENERAL_HOSPITAL^MR||LINDQVIST^ERIK||19921102|M|||7 Mill St^^Lakeside^ON^K0K 1A0||(555)010-4455||||||999-01-2302 - NK1|1|LINDQVIST^SARA|MTH||(555)010-4456 - PV1|1|E|ED^BAY3^1||||1234^HALE^SIMON|||MED|||||||||V5002|||||||||||||||||||||||||20261021091200 + PV1|1|EMER|ED^BAY3^1||||1234^HALE^SIMON|||MED|||||||||V5002|||||||||||||||||||||||||20261021091200 + ZLB|ED|STAT-t /sample/transforms/patient-api.tomlruns a transform file on its own and prints the JSON. It exits with 3 when a transform fails on a message, so a build can test a transform change against sample messages. The console's Transform tester does the same for pasted messages:
The Transform tester: nothing is sent or stored.
Keep RESEARCH_SALT secret, and the same: changing it changes every hash. Review what else your messages carry (OBX notes, NTE, Z-segments) before sending them outside the hospital. Run each transform on a day of real messages with hl7-edge-gateway transform -c to find codes your tables don't have yet.
Or run every step with ./demo.sh (Linux, macOS, Git Bash) or ./demo.ps1 (PowerShell). Clean up with docker compose down -v. More in Transforms.