A local InterSystems IRIS for Health sandbox plus a one-hotkey round trip from Notepad++: edit a transform, hit the key, see the transformed message.
Built for learning IRIS interoperability coming from a Mirth/BridgeLink background.
The obvious reading of "a Notepad++ plugin that runs IRIS transforms" is a new plugin. It should not be. Two reasons:
- PipeHat already is the plugin. It owns HL7 loading, parsing, the message tree, and field-aware Compare Views. A second plugin would rebuild all of it to add one button.
- The hard part is not the editor, it is the execution engine. DTL only runs inside IRIS. Anything you build in Notepad++ is a client to an IRIS instance you still have to stand up. Stand up the instance first; the editor integration is then a four-line NppExec script.
So v1 is: Docker sandbox + a bun driver + NppExec. v2 folds the driver into PipeHat as a command so the result lands in the second view and Compare Views highlights every changed field automatically.
# Start Docker Desktop first, then:
cd C:\opencode\iris-lab
docker compose up -d # ~1 GB pull the first time, a few minutes
bun setup.ts # one-time: enable interoperability on USER
bun xform.ts # the round tripbun setup.ts must print ENSEMBLE-ENABLED=1 and HL7-CLASS=1. If it does not,
nothing downstream will compile, because EnsLib.HL7.* is only mapped into
interoperability-enabled namespaces.
Management Portal: http://localhost:52773/csp/sys/UtilHome.csp (_SYSTEM / SYS).
License: Community Edition is development-and-learning only. Fine for this. Not fine for anything client-facing.
.\lab.ps1 # transform lab\input.hl7
.\lab.ps1 -In messages\meditech-a01.in.hl7 # transform that file insteadIt asks for a password the first time in a shell and remembers it for that shell
only. Nothing else to set: the instance, the namespace, and the path to
irissession.exe are all discovered or defaulted. Add -Instance only if you
have more than one install and it picks the wrong one.
The environment variables underneath, if you would rather drive xform.ts
directly:
$env:IRIS_MODE = "local"
$env:IRIS_INSTANCE = "<from `iris list`>"
$env:IRIS_NAMESPACE= "USER"
Get-Content lab\input.hl7 -Raw | bun xform.tsThree things differ from the container, all of them discovered the hard way:
There is no iris session on Windows. That spelling is UNIX-only; iris.exe
rejects session as an invalid parameter. The scriptable terminal is a separate
executable, irissession.exe, in the instance's own bin directory. xform.ts
looks for it in three places, in order: $env:IRIS_SESSION_EXE, the directory:
line of iris list, then a scan of the standard install roots. That last one
exists because a stock Windows install puts iris.exe in the instance's own
bin directory and never adds it to PATH — so on a perfectly healthy machine,
iris list cannot run at all and used to fail with "Is IRIS installed and on
PATH?", which was both wrong and pointed at the wrong variable.
Keep every piped line short. The terminal wraps a long input line, and a
wrapped line is two ObjectScript commands rather than one. Each half is a
<SYNTAX> error, and because the break lands mid-string-literal neither error
names the real problem. xform.ts assigns paths to r/i/o/x first and
keeps the call that uses them under 60 characters for exactly this reason. It
never came up in docker, where /lab/input.hl7 is 14 characters.
Pipe LF, not CRLF. A PowerShell here-string ends its lines \r\n, the
trailing \r rides along into the password, and you get Access Denied on a
password that is visibly correct. Same for the UTF-8 BOM PowerShell 5.1 writes
ahead of the first byte on a pipe to a native executable — that one shows up as
Username: _system. xform.ts sidesteps both by writing bytes through
Bun.spawnSync instead of going through a shell.
A native install asks for a password. The container has no web application
and makes no auth decision, so this never comes up there. A piped session gets
Access Denied on the first line unless it supplies credentials:
$env:IRIS_USER = "<your iris username>"
$env:IRIS_PASSWORD = Read-Host "IRIS password"Read-Host keeps it out of your shell history. Credentials go over stdin, not
command-line arguments — arguments are visible to every other user on the box
through the process list, which on a shared or work machine is not theoretical.
Don't persist them into a script or a machine-level environment variable.
Windows PowerShell 5.1 will show a red NativeCommandError on success.
Not a failure. xform.ts follows the UNIX split — the transformed message goes
to stdout so it can be piped, and the OK Lab.Transform ... ms status goes to
stderr so it never contaminates the message. PowerShell 5.1 wraps any stderr
output from a native executable in an ErrorRecord and renders it red, regardless
of exit code.
Check the thing that actually matters:
$LASTEXITCODE # 0 means it workedTo silence the noise, at the cost of losing the timing line:
Get-Content lab\input.hl7 -Raw | bun xform.ts 2>$nullPowerShell 7 does not do this. Neither does cmd.
The namespace needs interoperability enabled. Check it once:
zn "USER"
write ##class(%EnsembleMgr).IsEnsembleNamespace($namespace)
write ##class(%Dictionary.CompiledClass).%ExistsId("EnsLib.HL7.Message")Both must print 1. If the first is 0:
do ##class(%EnsembleMgr).EnableNamespace($namespace,1) — but only on an
instance that is yours. It modifies the namespace, and change control on a
shared dev engine belongs to someone else.
| File | What it is |
|---|---|
lab/input.hl7 |
The sample message. Paste any HL7 v2 here. |
lab/Transform.cls |
The file you edit. Your DTL. Must stay named Lab.Transform. |
lab/output.hl7 |
The result. Overwritten every run. |
src/Lab.Runner.cls |
The harness. Recompiles your transform on every run. |
Errors land in lab/output.hl7 prefixed with ###, on purpose — you should see
the compile error in the editor, not go digging through container logs.
xform.ts is a UNIX filter and nothing more:
stdin <- raw HL7
stdout -> transformed HL7
stderr -> diagnostics (compile errors, timings)
exit 0 = success, non-zero = failure
Nothing in it knows about Notepad++. Run it from a shell, a test script, CI, or any editor that can spawn a process:
Get-Content lab\input.hl7 -Raw | bun xform.tsPipeHat's External Transform Provider speaks exactly that contract. Add this
to PipeHat.providers in the Notepad++ plugin config dir:
iris.command = bun.exe C:\opencode\iris-lab\xform.ts
iris.workdir = C:\opencode\iris-lab
iris.timeout = 20000
iris.desc = InterSystems IRIS DTL
Then, with a message open and a second view showing:
Ctrl+Alt+Shift+X— pick the providerCtrl+Alt+Shift+A— run it again
The result lands in the other view and PipeHat diffs it field by field automatically. PipeHat never learns the word "InterSystems"; it just runs a command. Any other engine you wrap the same way is another line in that file.
src/Lab.Check.cls is the regression harness. Give it a message and the output
you expect, and it tells you whether the transform still produces it. It is the
ObjectScript port of hl7-bench/check.ts, so a case written for one runs in the
other.
It exists because a shared dev engine is usually remote. There is no filesystem
you can drop a messages/ folder into, so the cases travel inside the class, in
an XData block, and the whole gate is one file you can paste into Studio.
zn "USER"
do $system.OBJ.Load("C:\SIA\iris-lab\src\Lab.Check.cls","ck-d")
do ##class(Lab.Check).RunAll() ; every case in the XData block
do ##class(Lab.Check).RunAll("a01") ; only cases whose name contains "a01"PASS seed-a01 4 segments identical
1/1 cases passed
A failure prints the line number and both sides, which is the only output that saves you a trip to the Message Viewer:
FAIL wrongwant
line 3
got PID|1||MRN12345^^^LABMRN^LAB||DOE^john^q^^^^L||19800115|MALE|...
want PID|9||MRN12345^^^LABMRN^MR||doe^john^q^^^^L||19800115|M|...
Cases are plain text in the Cases XData block, one ### directive per line:
### case seed-a01
### mask MSH:10
### in
MSH|^~\&|SENDAPP|...
PID|1||MRN12345|...
### want
MSH|^~\&|IRISLAB|...
PID|1||MRN12345|...
### mask is the part people skip and then fight for an hour. Any field the
transform fills from the clock or a counter differs on every run, so the gate
goes red on a transform that is perfectly correct. Mask those fields and they
are blanked on both sides before comparison. MSH:10 (control ID) and
MSH:7 (timestamp) are the usual two. Multiple fields go on one line, space
separated, and the numbering is schema numbering, so MSH:10 is the control ID
exactly as PipeHat shows it.
A case with ### reject and no ### want asserts that the transform refuses
the message. Read the caveat below before trusting one.
Do not hand-write the ### want block. Generate it from a run you have already
eyeballed:
do ##class(Lab.Check).Emit("C:\SIA\iris-lab\lab\input.hl7","seed-a01","MSH:10")That prints a finished case block. Read it, confirm it is the output you actually wanted, then paste it into the XData. A golden captured from a run you never looked at freezes the bug in place and calls it the spec.
On a box where you do have a filesystem, RunDir reads the same layout
hl7-bench uses, so bench cases and IRIS cases are the same files:
| File | Meaning |
|---|---|
<name>.in.hl7 + <name>.want.hl7 |
Transform the first, expect the second |
<name>.reject.hl7 |
Expect the transform to refuse it |
<name>.mask |
Optional. Fields to blank, one per line or space separated |
do ##class(Lab.Check).RunDir("C:\SIA\iris-lab\lab\checkcases")
do ##class(Lab.Check).RunDir("C:\SIA\iris-lab\lab\checkcases","a08")An .in.hl7 with no matching .want.hl7 prints SKIP and is left out of the
denominator, so a half-written case cannot pad the pass rate.
In hl7-bench, refusing a message is a real outcome. In IRIS it is not. A DTL is strictly one message in, one message out. It cannot filter and it cannot refuse. Deciding whether a message gets sent at all is the routing rule's job, and the routing rule is not in this class.
So a green reject case here means "the DTL also guards itself", which is worth
having. It does not mean the interface refuses that event. If the rule sends
an A03 to a transform that throws on A03, you have not filtered anything, you
have manufactured an error queue. Test the filter in the rule, and test the
guard here, and do not let one stand in for the other.
Compiles and runs green on IRIS for Health 2026.1 (container). Every branch has
been exercised deliberately, including both rejection outcomes, both mask offset
rules, the skip path, and a filter that matches nothing. The MSH offset in
MaskSegment is the subtle one: MSH-1 is the field separator, so MSH:3 is
the third pipe-piece while PID:3 is the fourth. Get that backwards and the
gate blanks the wrong field and still goes green, which is the worst outcome
available to a test harness.
| Mirth / BridgeLink | IRIS | Note |
|---|---|---|
| Channel | Production | The container for everything |
| Source connector | Business Service | EnsLib.HL7.Service.TCPService for MLLP |
| Destination connector | Business Operation | EnsLib.HL7.Operation.TCPOperation |
| Filter + Router | Business Process, usually a Routing Rule | Rules are their own editor |
| Transformer step (JavaScript) | DTL | XML, drawn as a graphical mapper |
msg['PID']['PID.5']['PID.5.1'] |
source.{PID:5.1} |
Same idea, different punctuation |
channelMap / globalMap |
Production settings, Ens.Util.* |
No direct equivalent to channelMap |
| JavaScript escape hatch | <code> block, ObjectScript |
Same role, different language |
| Message Browser | Message Viewer / Visual Trace | Visual Trace is genuinely better |
| Channel deploy | Production start/update |
The one that bites: MSH field numbering. In EnsLib.HL7, MSH:1 is the field
separator, so MSH:9 is the message type and MSH:10 the control ID — the schema
numbering, not the raw-pipe offset. PipeHat already honors this, so your instincts
transfer.
- Break it on purpose. Change
{PID:5.1}to{PID:99.1}and run. Read the error. Do it again with a bad XML tag. Learn what each failure looks like now, while the loop is two seconds long. - Work the DTL vocabulary —
<assign>,<if>,<foreach>,<subtransform>,<code>. The sample uses the first four. - Open the same class in the Management Portal (Interoperability > Build > Data Transformations). Same file, graphical view. Edit it there, watch the XML change here. That connection is the thing that makes DTL click.
- Then leave the sandbox and build an actual production: TCP service, routing rule, TCP operation. Point PipeHat's MLLP sender at it — you already have the test harness.
- ObjectScript proper comes after DTL, not before. You will have absorbed
half of it from
<code>blocks by then.
Reference: Developing DTL Transformations · DTL for HL7 · EnsLib.HL7.Message class ref
Run and green against IRIS for Health 2024.1.2 on a native Windows install,
2026-08-14. src/Lab.Runner.cls compiles and all five constructs in
seed/Transform.cls fire: <assign> literal, <assign> with an ObjectScript
expression, <if>/<true>, <foreach> over repeating fields, and a <code>
block. The docker path has not been re-run since.
One real defect surfaced on first run, and it is the same failure recipe 18
documents. ApplySchema used named paths (MSH:12, MSH:9.1) to resolve
the DocType — but a named path needs a resolved DocType, which is the entire job
of that method. So it read empty off every message, ResolveSchemaTypeToDocType
got ("", "_"), the guard skipped, and the bare catch swallowed it. No error,
no throw, nothing in the log. The fix is ordinal paths (1:12, 1:9.1).
That was half a fix, so the other half landed later. ApplySchema now returns a
%Status and distinguishes the four ways classification can fail, instead of
producing identical silence for all of them. Failing to classify is still not
fatal, because an unclassified message transforms perfectly well, but the run
now says so:
?? UNCLASSIFIED ERROR #5001: could not read MSH-12/MSH-9 off the message (version="" type="^")
OK Lab.Transform 232 ms
That is the original bug, deliberately reintroduced, naming itself. "No schema
loaded for 9.9:ADT_A01" is a different message for a different problem, which is
the entire point. BuildMap() is checked now too: it returns a status that used
to be discarded, and a message carrying a DocType it never built a map for is
worse than an unclassified one, because named paths then resolve to empty rather
than erroring and the next failure is silent as well.
The tell was a blank DocType in the OK line. Nothing else looked wrong,
because the DTL declares its own sourceDocType/targetDocType on the
<transform> element and does not depend on the runtime .DocType property —
so the transform kept working correctly while the harness quietly failed to
classify. Two independent things, one of them silent.
Debug the pieces by hand with:
docker exec -it iris-lab iris session IRIS # container
.\lab.ps1 # native installthen zn "USER" and call the pieces by hand.
No PHI in lab/. Ever.