Skip to content
LambogeniusDocs

HL7 Edge Gateway

Transforms

Give each destination its own version of a message: rules that change fields and segments, code tables, and Liquid templates that make JSON, XML, text or another HL7 message.

Runs onWindows · Linux · macOS · DockerFree developer license for test traffic. Production traffic needs a paid license.

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
OperationDoes
setWrites values, creating the field, component or segment if it isn't there.
copyCopies 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.
moveCopies, then clears the source.
clearEmpties fields that are there; it doesn't add segments.
mapReplaces 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").
deleteRemoves every occurrence of a segment (not MSH).
addAdds a segment after the last occurrence of after, or at the end, with its fields.
replaceRewrites text with a regular expression; with can use $1 for groups.
dateReformats 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.
hashReplaces 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 hex

Only 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 line

Templates

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 templateIs
msgThe message, after the rules before the template
control_idMSH-10
message_typeMSH-9, such as ADT^A01
route, destinationThe route's and the destination's names
receivedWhen the gateway received the message, in ISO 8601 UTC
tablesThe transform's code tables: {{ tables.patient_class["E"] }}
FilterGives
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
jsonA JSON value, quoted and escaped (lists too)
xml, hl7Text 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.

Service Bus subscription filters

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 have

It 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 }} }