A destination with a transform receives the message changed by it; a destination without one gets the message as it arrived. A transform is a list of steps, run in order just before each delivery: rules that change the HL7 message, and, last, an optional Liquid template that turns it into JSON, XML, text or another HL7 message. Transforms arrived in 0.6.0.
Where a transform goes
On a destination, as a file (relative to the configuration) or inline, in [[route.destinations]] or a route's single destination = { … }. Each destination of a route can have its own, so one feed can reach a lab system as HL7 with its codes, an API as JSON, and a research archive de-identified.
[[route.destinations]]
name = "lab"
type = "mllp"
address = "lis.hospital.local:6661"
transform = "transforms/lab.toml" # a file, relative to the configuration
[[route.destinations]]
name = "research"
type = "sftp"
# …
transform = { step = [ # or inline
{ hash = { fields = ["PID-3.1"], salt = "${RESEARCH_SALT}" } },
{ clear = ["PID-5", "PID-11", "PID-13"] },
] }The gateway loads every transform when it starts, and hl7-edge-gateway check does too: a transform with a mistake, a table it doesn't have, or a template that doesn't parse stops it with an error that names the step.
Rules
Each [[step]] names one operation. Paths are the ones filters use, so PID-3[1].1 is the first repetition's first component; a path without an index changes every occurrence and repetition. Values are escaped for HL7 as they are written, and the message's own separators are kept.
# transforms/lab.toml: what the lab system receives
[[step]]
set = { "MSH-5" = "LIS", "MSH-6" = "LAB" }
[[step]]
map = { field = "PV1-2", table = "patient_class" }
[[step]]
delete = { segment = "NTE" }
[[step]]
date = { field = "PID-7", to = "%Y-%m-%d" }
[tables.patient_class]
E = "EMER"
I = "INPT"
O = "OUTPT"
"*" = "OTHER" # any other code$ hl7-edge-gateway transform -t transforms/lab.toml --diff adt-a01.hl7
transform OK: lab.toml: 4 rules
adt-a01.hl7 ADT00001 ADT^A01
- MSH|^~\&|MEDITECH|HOSP|LAB|LABFAC|20261016120500||ADT^A01^ADT_A01|ADT00001|T|2.5.1
+ MSH|^~\&|MEDITECH|HOSP|LIS|LAB|20261016120500||ADT^A01^ADT_A01|ADT00001|T|2.5.1
EVN|A01|20261016120500
- PID|1||123456^^^HOSP^MR||DOE^JANE||19800101|F
+ PID|1||123456^^^HOSP^MR||DOE^JANE||1980-01-01|F
- PV1|1|I|ICU^101^A
+ PV1|1|INPT|ICU^101^A| Operation | Does |
|---|---|
set | Writes values, creating the field, component or segment if it isn't there. |
copy | Copies the first value found at one path to another. Copied into a component, a whole field's separators are escaped, so nothing spills into the next component. |
move | Copies, then clears the source. |
clear | Empties fields that are there; it doesn't add segments. |
map | Replaces a code through a code table. A code the table doesn't have, with no "*" entry, is kept (missing = "keep"), emptied ("clear") or fails the delivery ("fail"). |
delete | Removes every occurrence of a segment (not MSH). |
add | Adds a segment after the last occurrence of after, or at the end, with its fields. |
replace | Rewrites text with a regular expression; with can use $1 for groups. |
date | Reformats an HL7 timestamp: %Y %m %d %H %M %S, %f for fractions, %z or %:z for the zone, or "iso" for ISO 8601 at the precision the message has. |
hash | Replaces values with a salted SHA-256, in hex, so the same patient still links up across messages but can't be read back. The salt needs at least 16 characters; keep it in an environment variable. |
set = { "MSH-5" = "LIS", "PV1-3.1" = "4W" } # creates the field, and the segment, if missing
copy = { from = "PID-3[1].1", to = "PID-2" } # the first value found
move = { from = "PV1-19", to = "PV1-50" } # copy, then clear the source
clear = ["PID-11", "PID-13"]
map = { field = "PV1-2", table = "patient_class" } # missing = "keep" (default), "clear" or "fail"
delete = { segment = "NTE" } # every NTE in scope
add = { segment = "ZPI", after = "PID", fields = { "ZPI-1" = "EDGE" } }
replace = { field = "PID-13", pattern = "[^0-9]", with = "" } # a regular expression
date = { field = "PID-7", to = "%Y-%m-%d" } # or "iso"
hash = { fields = ["PID-3.1"], salt = "${HASH_SALT}" } # salted SHA-256, as hexOnly some messages, or each group
when is a filter: the step applies only to messages it matches. foreach applies the step in each segment group, with paths counted inside the group; with filter keys too, only in the groups they match. Steps run in order, and each one, its when included, sees the message as the steps before it left it: after a map of PV1-2, when sees the mapped code, so put a step that tests the original code before the map.
# Only for ADT^A08: drop the visit's patient location
[[step]]
when = { message_type = ["ADT^A08"] }
clear = ["PV1-3"]
# In each OBR group: the order's test code into each OBX-4
[[step]]
foreach = { segment = "OBR" }
copy = { from = "OBR-4.1", to = "OBX-4" }
# Only in the groups whose result is critical
[[step]]
foreach = { segment = "OBR", fields = { "OBX-8" = ["HH", "LL"] } }
add = { segment = "NTE", fields = { "NTE-3" = "Critical result: call the ward" } }Code tables
Tables translate one system's codes into another's, for map and for the template filter lookup. Write them in the transform, or in a CSV file with a code and a value on each line. A "*" entry is the value for every code the table doesn't list. Codes are compared exactly.
[tables.patient_class] # inline: one key per code
E = "EMER"
"*" = "OTHER"
[tables]
facility = "tables/facility.csv" # or a CSV file: code,value per lineTemplates
The last step can be a template in Liquid, the language Azure Logic Apps uses for JSON transforms. output says what it makes; the gateway checks it before sending (JSON and XML must parse, HL7 must start with MSH) and sends it with that content type to HTTP and Service Bus destinations, unless the destination sets content_type. MLLP destinations and HL7 batch files take only output = "hl7".
# transforms/adt-json.toml
[[step]]
template = "adt-json.liquid"
output = "json" # "json", "xml", "hl7" or "text"
# content_type = "application/fhir+json" # instead of application/json
[tables.patient_class]
E = "emergency"
I = "inpatient"
"*" = "other"{
"event": {{ message_type | json }},
"id": {{ control_id | json }},
"patient": {
"mrn": {{ msg | field: "PID-3.1" | json }},
"family": {{ msg | field: "PID-5.1" | json }},
"given": {{ msg | field: "PID-5.2" | json }},
"born": {{ msg | field: "PID-7" | iso_date | json }}
},
"visit": {
"class": {{ msg | field: "PV1-2" | lookup: "patient_class" | json }},
"location": {{ msg | field: "PV1-3.1" | json }}
},
"received": {{ received | json }}
}For the sample ADT, the API receives:
{
"event": "ADT^A01",
"id": "ADT00001",
"patient": {
"mrn": "123456",
"family": "DOE",
"given": "JANE",
"born": "1980-01-01"
},
"visit": {
"class": "inpatient",
"location": "ICU"
},
"received": "2026-10-21T21:49:07Z"
}| In a template | Is |
|---|---|
msg | The message, after the rules before the template |
control_id | MSH-10 |
message_type | MSH-9, such as ADT^A01 |
route, destination | The route's and the destination's names |
received | When the gateway received the message, in ISO 8601 UTC |
tables | The transform's code tables: {{ tables.patient_class["E"] }} |
| Filter | Gives |
|---|---|
msg | field: "PID-5.1" | The first value at a path, as text (HL7 escapes undone); empty if there is none |
msg | fields: "PID-3.1" | Every value at a path: each occurrence and repetition |
msg | segments: "OBX" | Each segment, to loop over and read with field |
msg | groups: "OBR" | Each segment group; groups: "OBR", "ORC" ends each group at an ORC |
json | A JSON value, quoted and escaped (lists too) |
xml, hl7 | Text escaped for XML, or for an HL7 field |
lookup: "table" | The value from a code table, or the table's "*" entry, or empty |
iso_date, hl7_date: "%d/%m/%Y" | An HL7 timestamp in ISO 8601, or in a format of your own |
Liquid's own filters and tags (if, unless, case, append, upcase, default and the rest) work as usual. Liquid's for takes a variable, not a filter, so assign the segments or groups first:
{% assign orders = msg | groups: "OBR" %}
<results id="{{ control_id | xml }}">
{% for o in orders %} <order test="{{ o | field: "OBR-4.1" | xml }}">
{% assign obx = o | segments: "OBX" %}{% for x in obx %} <value code="{{ x | field: "OBX-3.1" | xml }}" flag="{{ x | field: "OBX-8" | xml }}">{{ x | field: "OBX-5" | xml }}</value>
{% endfor %} </order>
{% endfor %}</results>{% include "part.liquid" %} reads another template from the same folder. Templates can't read other files, reach the network or see environment variables. A template stops at max_output_bytes (1 MiB by default) or after timeout ("2s"), set at the top of the transform file.
When a transform fails
The queue keeps each message as it arrived, and the transform runs at each delivery attempt. So a transform you fix applies to the messages still waiting, and other destinations aren't affected. A message the transform can't handle (a code a map with missing = "fail" doesn't know, a date that isn't one, a template error or output that isn't what output says) is rejected for that destination: it goes to dead letters or blocks the queue as on_reject says, or, in pass-through, the sender gets AR. The reason names the step, such as transform failed: step 2 (map PV1-2): PV1-2 is "Z", which table patient_class doesn't have, and hl7edge_transform_failures_total counts them.
A Service Bus message whose transformed payload isn't HL7 has no hl7_* application properties, since they come from MSH. Subscription filters on them won't see it.
Check a transform
The transform command
hl7-edge-gateway transform runs a transform file, or a destination's transform from a configuration, on message files, and prints what the destination would receive. Nothing is sent. With --diff, HL7 output shows the segments that changed: - as it arrived, + as sent.
# A transform file, on sample messages: the output
hl7-edge-gateway transform -t transforms/adt-json.toml messages/*.hl7
# HL7 output: only the segments that changed
hl7-edge-gateway transform -t transforms/lab.toml --diff messages/*.hl7
# A destination's transform, from a configuration
hl7-edge-gateway transform -c gateway.toml --route adt-out --destination lab messages/*.hl7$ hl7-edge-gateway transform -c gateway.toml --destination strict adt-z.hl7
transform OK: inline: 1 rule
adt-z.hl7 T3 ADT^A01 FAILED
step 1 (map PV1-2): PV1-2 is "Z", which table class doesn't haveIt exits with 0 when every message transformed, 1 when the transform isn't valid, and 3 when it failed on a message.
In the admin console
The routes table shows each destination's transform. Transform tester runs a destination's transform on pasted messages, with the HL7 changes marked; it is POST /api/transform-test with { "route": "…", "destination": "…", "message": "…" } in the admin API. With allow_payload_view, the message viewer shows a queued or dead-lettered message as received and as sent to its destination.
In Kubernetes
The Helm chart's transformFiles holds transform files, templates and CSV tables by file name, next to the configuration. Inline transforms go in destinations as they are.
routes:
- name: adt-out
port: 7890
source: { type: mllp }
destinations:
- { name: api, type: http, url: "https://api.example.org/adt", transform: adt-json.toml }
transformFiles:
adt-json.toml: |
[[step]]
template = "adt-json.liquid"
output = "json"
adt-json.liquid: |
{ "mrn": {{ msg | field: "PID-3.1" | json }} }