Initial release for the "AI Agents in Interoperability Workflows" contest.
Entry for the InterSystems Developer Community contest “AI Agents in Interoperability Workflows” (2026).
Triage of HL7 v2 ORU^R01 lab results on InterSystems IRIS for Health Interoperability.
Deterministic rules decide CRITICAL. An AI agent (%AI.Agent, AI Hub SDK) explains the abnormal
results and decides RELEASE or HOLD_FOR_REVIEW. The most severe decision of the message wins.
All data in this repo is synthetic. The critical limits are fictional. Not for clinical use.
Article on the Developer Community: Rules First, Model Second, Human Last: Triaging HL7 v2 Lab Results with an AI Hub Agent
DPI-I-689: Use InterSystems Interoperability as a Traceability tool for GenAI LLM pipelines
Every LLM call of the production is an Interoperability message: the TriageProcess sends an AgentRequest to the
AgentOperation, the only component that talks to the LLM provider, and gets an AgentResponse back. Each call is
therefore persisted in the message store and shown in Visual Trace, in the same session as the lab result
(TriageRequest), the triage output and, for held messages, the review task. No separate tracing SDK or service is
involved.
Metadata visible in Visual Trace:
| Metadata | Where |
|---|---|
| Prompt version | AgentResponse.PromptVersion |
| Input / output tokens | AgentResponse.InputTokens, AgentResponse.OutputTokens |
| Latency | AgentResponse.LatencyMs (per call), TriageResponse.AgentLatencyMs (sum of the calls) |
| Attempts | AgentRequest.Attempt (one message per attempt), TriageResponse.AgentAttempts |
| Fallback | Per exam in the TriageResponse: DecidedBy = FALLBACK, ReasonCode = AGENT_FALLBACK; AgentStatus says why |
Vector Search is not used: the idea suggests it, but it is not required.
See Reproducible example: LLM traceability in Visual Trace.
| Aspect | Langfuse | LabTriage Agent (IRIS Interoperability) |
|---|---|---|
| How calls are captured | SDK / OpenTelemetry instrumentation in the application code, sent to a separate Langfuse backend | Each LLM call is already an Interoperability message (AgentRequest → AgentResponse); no extra SDK or service |
| Where trace data lives | Langfuse Cloud or a self-hosted multi-service deployment | The same IRIS instance and message store as the production |
| Trace view | Nested tree of spans with timing, tokens and cost per node | Visual Trace sequence diagram between business hosts; metadata in the message Body |
| Tokens and cost | Input/output tokens; cost computed automatically from model price definitions; dashboards | Input/output tokens and latency per successful call; no cost calculation or dashboards |
| Prompt versioning | Full prompt management: versions, labels (staging/production), rollbacks, diffs, linked to traces | PromptVersion compiled into the agent class and stored on every response that used the prompt (REAL calls and replayed recordings); changing a prompt is a code change |
| Retries and fallback | Captured as generic spans if instrumented | Business-level: each attempt is its own AgentRequest with Attempt; a retry after a contract violation also carries the rejected answer and the violation. Fallback is recorded per exam (DecidedBy = FALLBACK) |
| Evaluation | LLM-as-a-Judge, annotation queues, custom scores | No quality evaluation: every answer is validated against a fixed contract (AgentContract), and held results go to human review through a Workflow task in the same session |
| Link to the business flow | Needs IDs/metadata to correlate traces with business events | The LLM call sits in the same session as the triage request built from the HL7 message, the triage response sent to the decision file operation and, for held results, the review task |
| Licensing / hosting | MIT core, self-host or Cloud; enterprise add-ons under a commercial license | Nothing beyond IRIS itself |
Langfuse is the better choice for teams that need evaluation pipelines, cost dashboards and prompt management across many LLM applications. The LabTriage approach fits a narrower but common case: the LLM is one step inside an interoperability workflow, and the goal is to see that step in the same trace, database and tooling as the rest of the message flow, with no extra service to deploy. The two are not mutually exclusive.
env_file with required: false)This app was built and tested on the AI Hub Early Access build 2026.3.0AI.154.0 (IRIS for Health Community,
Build 154U), set in line 1 of the Dockerfile (ARG IMAGE=irishealth-community:2026.3.0AI.154.0).
The EAP portal now also offers 2027.1 (Community build 109). The organizers said the judges will load the build
that matches each app; for this app, that is 2026.3.0AI.154.0.
The contest rules require this base image, and no public registry has it. Load it once:
irishealth-community-2026.3.0AI.154.0-docker.tar.gz (x64) from the InterSystems AI Hubdocker image load -i irishealth-community-2026.3.0AI.154.0-docker.tar.gz
docker tag docker.iscinternal.com/docker-intersystems/intersystems/irishealth-community:2026.3.0AI.154.0 \
irishealth-community:2026.3.0AI.154.0
git clone https://github.com/AzizCotrim/iris-lab-triage-agent.git
cd iris-lab-triage-agent
docker compose up -d --build
LabTriage.Production starts on its own when the container starts (1 to 2 minutes in REPLAY mode).docker compose ps shows iris as healthy once the production is running..env file: the default mode is REPLAY (recorded LLM answers, no API key).Gemini through the AI Hub SDK, model gemini-3.5-flash-lite by default.
To use the real model:
cp .env.example .env
# set GOOGLE_API_KEY= and LABTRIAGE_AGENT_MODE=REAL
docker compose up -d
LABTRIAGE_MODEL (optional) changes the model without a recompile.
Agent modes (LabTriage.Operation.AgentOperation):
| Mode | What it does |
|---|---|
| REAL | Calls Gemini. Records every OK answer in data/replay/. |
| SIMULATION | Never calls the provider. Returns the outcome in the SimulatedOutcome setting (HOLD_ALL, RELEASE_ALL, TIMEOUT, INVALID, …). |
| REPLAY | Returns the answer recorded for the same prompt version and the same input. With no recording, the exams fall back to HOLD_FOR_REVIEW. |
Which mode runs: the Mode setting of AgentOperation, if set. Otherwise LABTRIAGE_AGENT_MODE. Otherwise REPLAY.
Note: REAL mode rewrites data/replay/ on every OK answer. Run git restore data/replay after a REAL run.
One sample by hand:
cp data/samples/13-critical-and-abnormal.hl7 data/in/
# a few seconds later:
ls data/out/*/
cat data/out/CRITICAL/SYN-ORU-0013.final.json
All 20 samples (needs python3). The script sets Mode=REPLAY, deletes the files in data/out and data/error,
drops every sample into data/in and checks each result (about 1 minute):
tests/e2e/samples.sh
Human review (needs python3): completes the open review tasks, then sends samples 06 and 02 through the Workflow
review (Release and KeepOnHold) and checks the tasks and the .review.json files (about 30 seconds). It also
deletes the files in data/out and data/error:
tests/e2e/workflow.sh
Unit tests (96 %UnitTest tests):
docker compose exec -T iris iris session IRIS -U IRISAPP <<'EOF'
Set ^UnitTestRoot="/home/irisowner/dev/tests/UnitTest"
Do ##class(%UnitTest.Manager).RunTest("LabTriage","/recursive/nodelete")
Halt
EOF
Management Portal: http://localhost:52773/csp/sys/UtilHome.csp, user SuperUser, password SYS
(local dev credentials).
Production page: http://localhost:52773/csp/healthshare/irisapp/EnsPortal.ProductionConfig.zen?PRODUCTION=LabTriage.Production
Every LLM call of the production is an Interoperability message: the TriageProcess sends an
AgentRequest to the AgentOperation and gets an AgentResponse back. One command shows three
cases in REPLAY mode (no API key, no new recordings):
tests/e2e/trace-example.sh
It drops the 3 messages of data/trace-example/ into data/in, checks each session in
Ens.MessageHeader, and prints the SessionId, the final decision and a Visual Trace link for each
case. It sets the AgentOperation Mode back to its previous value at the end.
| Case | MSH-10 | What happens | Agent messages in the trace | Final decision |
|---|---|---|---|---|
| a | SYN-TRACE-0001 |
Critical potassium (6.8): the rule decides, the agent is never called | none | CRITICAL (CRITICAL_HIGH, decided by RULE) |
| b | SYN-TRACE-0002 |
Abnormal potassium (6.2): the agent answer comes from a recording in data/replay/ |
AgentRequest + AgentResponse with Status=OK, PromptVersion, InputTokens, OutputTokens, LatencyMs |
HOLD_FOR_REVIEW (AGENT_FLAGGED, decided by AGENT) |
| c | SYN-TRACE-0003 |
Abnormal glucose (150) with no recording: REPLAY answers NO_KEY, the process falls back |
AgentRequest + AgentResponse with Status=NO_KEY and ErrorText |
HOLD_FOR_REVIEW (AGENT_FALLBACK, decided by FALLBACK) |
Where to click. Open the printed link (user SuperUser, password SYS). In the sequence diagram:
AgentResponse arrow (AgentOperation → TriageProcess) and open the Body tab:PromptVersion, InputTokens, OutputTokens, LatencyMs, Provider, Model and Status;AgentRequest arrow (TriageProcess → AgentOperation): Attempt is the retry number,Payload is the exact sanitized JSON sent to the model (no patient identity);TriageResponse arrow (TriageProcess → DecisionFileOperation): AgentAttempts,AgentStatus, AgentLatencyMs and, per exam, DecidedBy / ReasonCode (FALLBACK / AGENT_FALLBACKIn REPLAY, the metadata comes from the recording of the original REAL call. Cases b and c open a
review task each in the Workflow Inbox (samples.sh and workflow.sh complete them).

Case b (SYN-TRACE-0002): the AgentResponse open in the Body tab, with PromptVersion, InputTokens /
OutputTokens and LatencyMs.
flowchart LR
IN["data/in/*.hl7"] --> FI["FileInbound
parse ORU^R01"]
FI -- "invalid file" --> EFO["ErrorFileOperation"] --> ERR["data/error/"]
FI -- "TriageRequest" --> TP["TriageProcess
rules"]
TP -- "abnormal exams
(sanitized)" --> AO["AgentOperation
%AI.Agent"]
AO -- "AgentResponse" --> TP
TP -- "CRITICAL_ALERT, then FINAL" --> DFO["DecisionFileOperation"] --> OUT["data/out/"]
TP -- "HOLD_FOR_REVIEW:
TaskRequest (async)" --> LR["LabReviewer
Workflow Operation"]
LR <--> INBOX["Workflow Inbox
(reviewer)"]
LR -- "TaskResponse
(after the action)" --> TP
TP -- "ReviewResult" --> DFO
For each exam (OBX), in this order:
LabTriage.Rules.CriticalLimits): fictional limits from LabTriage.ExamCatalog, or a criticala-b, <x, >x) plus the flag: normal is released, unreadable goes to review,LabTriage.Util.Sanitizer).LabTriage.Util.AgentContract validates the answer. A bad answer, timeout or provider error gets one retry.The message decision is the most severe one: CRITICAL > HOLD_FOR_REVIEW > RELEASE.
Supported exams: hemoglobin (718-7), glucose (2345-7), potassium (2823-3), creatinine (2160-0), sodium (2951-2).
You can edit the flag categories in the Lookup Table LabTriage.FlagCategory (defaults in config/FlagCategory.xml).
Main reason codes:
| decidedBy | reasonCode | List |
|---|---|---|
| RULE | NORMAL |
released |
| RULE | CRITICAL_LOW, CRITICAL_HIGH, CRITICAL_LAB_FLAG |
critical |
| RULE | UNSUPPORTED_EXAM, NOT_FINAL, UNEXPECTED_UNIT, INVALID_VALUE, UNKNOWN_FLAG, UNPARSEABLE_RANGE, DUPLICATE_EXAM |
review |
| AGENT | AGENT_RELEASED / AGENT_FLAGGED |
released / review |
| FALLBACK | AGENT_FALLBACK (agent failed), PROCESSING_ERROR (exception) |
review |
Data folders (data/):
| Folder | Content |
|---|---|
in/ |
Input: drop *.hl7 files here |
out/ |
RELEASE/, HOLD_FOR_REVIEW/, CRITICAL/ (<MSH-10>.final.json), ALERTS/ (<MSH-10>.critical.json) and REVIEWED/ (<MSH-10>.review.json) |
error/ |
Rejected files: a copy of the input and <file>.error.json |
archive/ |
Processed input files (timestamp suffix) |
replay/ |
Recorded LLM answers for REPLAY mode |
samples/ |
20 synthetic ORU^R01 test messages |
trace-example/ |
The 3 synthetic messages of the Visual Trace example (tests/e2e/trace-example.sh) |
data/out/CRITICAL/SYN-ORU-0013.final.json (sample 13, REPLAY mode):
{
"messageControlId": "SYN-ORU-0013",
"outputType": "FINAL",
"orderId": "ORD-1013",
"patientId": "SYN-000113",
"decision": "CRITICAL",
"released": [
{"code": "2160-0", "name": "Creatinine", "decidedBy": "RULE", "reasonCode": "NORMAL",
"reason": "Within reference range."}
],
"review": [
{"code": "2345-7", "name": "Glucose", "decidedBy": "AGENT", "reasonCode": "AGENT_FLAGGED",
"reason": "Glucose is clearly outside its reference range, requiring review of exam 2345-7."}
],
"critical": [
{"code": "2823-3", "name": "Potassium", "decidedBy": "RULE", "reasonCode": "CRITICAL_HIGH",
"reason": "Potassium 6.8 mmol/L is above the critical high limit of 6.2 mmol/L (fictional limit)."}
],
"agent": {"called": true, "attempts": 1, "status": "OK", "latencyMs": 798,
"provider": "replay", "model": "gemini-3.5-flash-lite"},
"emittedAt": "2026-11-03T20:20:54.074Z"
}
data/out/ALERTS/SYN-ORU-0013.critical.json has the same shape, with "outputType": "CRITICAL_ALERT" and only the
critical list. All times are UTC.
data/error/10-malformed-no-obx.hl7.error.json:
{
"sourceFileName": "10-malformed-no-obx.hl7",
"reason": "The message has no OBX segment.",
"messageControlId": "SYN-ORU-0010",
"patientId": "SYN-000110",
"receivedAt": "2026-10-31T11:34:09Z"
}
A message whose final decision is HOLD_FOR_REVIEW also becomes a task in the Workflow Inbox, for the
LabReviewer role. This includes the agent fallback (no valid answer from the agent). CRITICAL messages don’t get a
task: the alert file is their escalation. RELEASE messages need no human.
SuperUser,SYS. The Inbox shows the tasks of the logged-in user only. iris.script creates the Workflow usersSuperUser and _SYSTEM at build time and adds both to LabReviewer, so each task reaches both of them.Lab results held for review: MSH-10 <MSH-10>, order <OBR-2>. The message lists the exams heldTo review a task:
The action goes to data/out/REVIEWED/<MSH-10>.review.json (sample 06, Release):
{
"messageControlId": "SYN-ORU-0006",
"outputType": "REVIEW",
"orderId": "ORD-1006",
"patientId": "SYN-000106",
"action": "Release",
"reviewer": "SuperUser",
"role": "LabReviewer",
"taskCreatedAt": "2026-11-01T13:11:55.082Z",
"taskCompletedAt": "2026-11-01T13:11:58.836Z",
"emittedAt": "2026-11-01T13:11:58.837Z"
}
After tests/e2e/samples.sh, the Inbox has one task per HOLD_FOR_REVIEW sample (10 tasks), ready to review.
The next run of tests/e2e/samples.sh or tests/e2e/workflow.sh first completes with Release the tasks still open
(tests/e2e/drain-review-tasks.sh), so their .review.json files are written and then deleted with the rest of
data/out.
The file name rules are the same as for .final.json: safe characters only, and a timestamp suffix for a repeated MSH-10.
The review never blocks or changes the triage. The .final.json is written right away, and the review is one more
event later, in its own file. The .final.json stays as it was.
The call is asynchronous: the Workflow Operation (EnsLib.Workflow.Operation) defers its response (DeferResponse)
until a reviewer acts, however long that takes. The TriageProcess sends the task with SendRequestAsync and writes the
ReviewResult in OnResponse. If the task cannot be created (OnError), the process only logs a warning.
-1-5) are not parsed (UNPARSEABLE_RANGE).reason..error.json.data/in, out, error, archive and replay are chmod 0777 by the init-perms service (local demo, synthetic data).config/FlagCategory.xml at build time.<METHOD DOES NOT EXIST>Sharding+2^STU, “System appears to have failed over from node buildkitsandbox”).| Test | What it checks |
|---|---|
%UnitTest classes in tests/UnitTest/ |
96 tests: CriticalLimits (31), ReferenceRange (23), AgentContract (31), DecisionFileOperation (7: review file, names, timestamps), AgentOperation (4: prompt version, tokens and latency in REPLAY and SIMULATION) |
tests/e2e/samples.sh |
The 20 samples in REPLAY: folder, decision, agent, reasonCode per exam, alert, error files. Leaves one open review task per HOLD_FOR_REVIEW sample |
tests/e2e/workflow.sh |
Workflow review of samples 06 (Release) and 02 (KeepOnHold): task in the Worklist, role users, Accept and action as SuperUser, .review.json |
tests/e2e/drain-review-tasks.sh |
Helper: completes every open review task (default action Release), as the Inbox does |
tests/e2e/agent-simulation.sh |
Sample 03 through 8 simulated agent outcomes (retry, fallback, fenced JSON) |
tests/e2e/trace-example.sh |
The 3 messages of data/trace-example/ in REPLAY: rule-only CRITICAL, agent call from a recording (prompt version, tokens, latency), agent call with no recording (NO_KEY, fallback); prints SessionId and Visual Trace link. Does not clean data/out. Leaves two open review tasks |
tests/e2e/clean-clone-check.sh |
A clean clone of HEAD without .env: build, production running, healthy, folder permissions, samples.sh, workflow.sh |
The e2e scripts need the iris service up. They change the AgentOperation Mode on the running production and set it
back to empty at the end (trace-example.sh sets back the value it found). clean-clone-check.sh stops this repo’s stack first and does not restart it.
├── Dockerfile, docker-compose.yml, iris.script, merge.cpf
├── .env.example
├── config/FlagCategory.xml flag -> NORMAL / ABNORMAL / CRITICAL defaults
├── data/ in, out, error, archive, replay, samples, trace-example
├── docs/dev-journal.md what was built and observed, day by day
├── docs/images/ README screenshots
├── src/LabTriage/
│ ├── Production.cls
│ ├── Service/FileInbound.cls
│ ├── Process/TriageProcess.cls
│ ├── Operation/ AgentOperation, DecisionFileOperation, ErrorFileOperation
│ ├── Agent/TriageAgent.cls %AI.Agent subclass: prompt, provider, model
│ ├── Rules/CriticalLimits.cls
│ ├── Util/ ReferenceRange, AgentContract, Sanitizer, FlagTable
│ ├── ExamCatalog.cls
│ └── Msg/ message classes
└── tests/ UnitTest/ and e2e/
openai, gemini) drop finish_reason, so a reply cut at the token limit looks like a complete answer.<%AICore>ProviderError instead of their dedicated codes.Both were found while building LabTriage Agent.
MIT, see https://github.com/AzizCotrim/iris-lab-triage-agent/blob/main/LICENSE.