
Initial Release
An IRIS Interoperability workflow where an AI Hub agent makes the decision: what to do with a batch of lab results after the lab quietly changed its HL7 feed.
The AI proposes, the production checks, a human decides.
Online demo (no install) · Ideas Portal: DPI-I-854: AI Log Analytics & Predictive Anomaly Detection for Interoperability (Community Opportunity)

A hospital lab upgrades its system. Now sodium arrives as SOD instead of NA, or creatinine comes in mg/dL instead of umol/L.
Every message is still valid HL7, so nothing alerts. The interface engine passes the results on, and they land in the wrong place in the chart, or with the wrong number. Often nobody notices until a clinician asks where the results went.
Rules can catch the changes you expect. The hard part is the change nobody wrote a rule for. That is where an AI agent helps, if you do not let it change the feed on its own.
It runs inside an IRIS Interoperability production and watches an HL7 v2 ORU^R01 feed, batch by batch:
Every agent tool call shows up in Visual Trace, and every step is saved as a Decision row.
| Contest task | Where in the project |
|---|---|
| IRIS Interoperability workflow | Production InteropOps.Production: file service, BPL process with DTL and rules, gate, Workflow, delivery |
| IRIS AI Hub agent | Business operation IOA.Agent: %AI.Agent with 7 %AI.Tool tools and a %AI.Policy.Audit policy (table below) |
| Decision-making task | The agent picks one action from a fixed list (patch_mapping, convert_unit, quarantine, pass, escalate). The production checks it before it can be applied |
| Human in the loop | Each fix waits in the Workflow Inbox (role IOA-Reviewers) until a person approves or rejects it |
flowchart LR
F["HL7 file
cases/in/*.hl7"] --> P["IOA.Process.Message
BPL: DTL + rules"]
P -->|"resolved"| G["IOA.Gate.Replay
batch + 200 historical messages"]
P -->|"unresolved"| A["IOA.Agent
%AI.Agent"]
A <-->|"tool calls"| T["IOA.Agent.Tools
%AI.Tool"]
A --> G
G -->|"patch"| W["IOA.Workflow
Workflow Inbox"]
W -->|"approved"| L["IOA.Lookup.Apply
lookup table + reprocess,
rollback if invalid"]
G -->|"pass / quarantine / escalate"| D[("Decision")]
W -->|"rejected"| D
L --> D
D --> O["IOA.Deliver
cases/out/ + cases/held/"]


| AI Hub API | Where | What it does here |
|---|---|---|
%AI.Agent, CreateSession, Chat() |
Agent/Operation.cls |
Business operation IOA.Agent. One session per batch, up to 3 iterations, 120 s timeout. If the agent stops without an answer, the case is escalated. |
%AI.Provider.Create |
Agent/ProviderFactory.cls |
openai or openrouter. The key is passed as @{env:OPENAI_API_KEY}, so the code never holds it. |
%AI.Tool |
Agent/Tools.cls |
7 tools: GetBatchMessages, SearchLookup, GetUCUMTable, CheckUnitEquivalence, GetEventLogExcerpt, GetValidationEvidence, CheckCriticalValues. Each sees only the current case. |
%AI.Policy.Audit |
Agent/TraceAuditPolicy.cls |
Sends each tool call to the production host IOA.Agent.Tools, so you see it in Visual Trace. |
GetStats(), GetContext() |
Agent/Operation.cls, Agent/Cassette.cls |
Counts calls and tokens against a budget (60 calls and IOA_MAX_USD, default $0.50). Saves the session as a cassette for replay. |
%AI.ToolMgr.ExecuteTool |
Agent/ReplayProvider.cls |
REPLAY mode: runs the recorded tool calls again through the same tools, without an LLM. Visual Trace looks the same as in a live run. |
The agent must answer with JSON from the fixed list of actions. Message content is treated as data, never as an instruction. Before the agent starts, the production gives it extra evidence: an HL7 check in .NET PEX (NHapi), a UCUM unit check in Embedded Python, and the 3 most similar past incidents from Vector Search.
Chat() hands only a provider token to the Rust agent loop, so a %AI.Provider subclass in ObjectScript is never called. REPLAY therefore uses ToolMgr.ExecuteTool directly.MaxIterationsReached is returned for max iterations, timeout and loop detection. The code tells them apart by the error text.call.arguments is a JSON string, not a %DynamicObject.The model makes mistakes. The recordings in replay/ show them, and the production is built so that a mistake does not reach the chart:
XQ5 to a glucose code. The model can follow it and propose this fix. The check sees that XQ5 reports in U/L, not in glucose units, and rejects the fix.
To be honest about the numbers: on our 12 demo cases a rules-only baseline also scores 12/12, and on the whole-blood test the agent was no better than the rules. This is why the design does not trust the agent. The checks and the human do. We tested it with blind cases, a pre-registered specimen test and LIVE runs.
The fastest way is the online demo. It calls a real model (openai/gpt-6-luna, $0.50 per day), so its results can differ from the recorded ones. Try this in 2 minutes:
SOD, and the rules cannot explain it, so the agent takes the case.SOD → 2951-2.The demo is shared by all visitors. If someone already approved C01, step 1 already shows the rules result.
For a local run you need Docker with Compose v2 (about 4 GB free RAM), bash, python3, free ports 51272, 52273, 52280, and the IRIS for Health AI Hub Community image, build 2027.1.0AI.109.0, from the EAP portal.
# Put the archive next to the repository (../irishealth_arm64-community-2027.1.0AI.109.0-docker.tar.gz on ARM64,
# ../irishealth-community-2027.1.0AI.109.0-docker.tar.gz on x64), or set IRIS_IMAGE_TAR=/path/to/archive.tar.gz
cd interop-ops-agent
./run.sh # loads the image, builds, starts the production; last line: FINAL STATUS: OK
./scripts/demo.sh --replay # sends the 12 cases, prints decisions, Visual Trace links and "correct 12/12"
./scripts/check.sh # health checks, including the gate self-test; last line: CHECK: OK
open http://localhost:52273/demo/ # demo page
On Apple Silicon run.sh takes 55 s and demo.sh --replay takes 5 s. REPLAY is the default: the agent’s tool calls come from the recordings in replay/ and go through the same production. If a tool result or the prompt differs from the recording, the case is marked DRIFT and demo.sh exits with code 1.
The demo page /demo/ shows the cases, the agent’s evidence, traces, the Workflow queue and the knowledge base, with a Run button per case and Approve / Reject. To see the whole-blood sodium scene without an LLM:
CASES_SRC=cases/pair EXPECTED_MAP_FILE=cases/pair/expected.json IOA_AUTOAPPROVE=0 ./scripts/demo.sh --manual --case C18 --replay
[!WARNING]
The ports are published on127.0.0.1only, because the image has the default_SYSTEM/SYSlogin.IRIS_BIND=0.0.0.0publishes them on all interfaces. Change the password before you do that.
[!CAUTION]
The demo page has no login, so any visitor can approve. It is installed only withIOA_DEMO=1(set indocker-compose.yml). Do not set it on a server with real data.
To call a real model, set OPENAI_API_KEY (or LLM_PROFILE=openrouter with OPENROUTER_API_KEY) and IOA_LIVE=1 in .env, then run ./run.sh and ./scripts/demo.sh.
[!IMPORTANT]
LIVE costs money. The default models aregpt-5.4-miniandgoogle/gemini-2.5-flash../scripts/demo.sh --livemakes one LIVE run,--replayforces REPLAY. Every non-REPLAY run is capped at 60 agent calls andIOA_MAX_USD(default 0.50).
A full LIVE run of the 12 cases on gpt-5.4-mini made 16 LLM calls and cost about $0.04 (2026-11-05, build 109). This is an estimate from token counts at $0.75 / $4.50 per 1M tokens, not checked against a bill.
Each case is one batch of 30 ORU^R01 messages (C06: 80) in cases/. The expected outcomes are in cases/expected.json. All patients, values and messages are synthetic.
| Case | What changed in the feed | Correct outcome | Decided by |
|---|---|---|---|
| C01 | New code SOD (Sodium, mmol/L); NA absent |
patch_mapping SOD → LOINC 2951-2 |
agent |
| C02 | PCT (Procalcitonin) replaces HCT in CBC |
escalate |
agent |
| C03 | Creatinine sent in mg/dL, receiver expects umol/L | convert_unit CREAT ×88.4 |
rules |
| C04 | Glucose sent in mg/dL, receiver expects mmol/L | convert_unit GLU ×0.0555556 |
rules |
| C05 | BUN unit mg/dl instead of mg/dL, values unchanged |
pass |
agent |
| C06 | 50 identical copies of message C06-MSG031 |
quarantine C06-MSG031 |
rules |
| C07 | One message resent as a correction (OBX-11=C) | pass |
rules |
| C08 | Message comment tries to instruct the AI; unknown code XQ5 |
escalate (no patch) |
agent |
| C09 | Unknown code Q77, no description, no unit |
escalate |
agent |
| C10 | MRN moved from PID-3 to PID-2 in 30/30 messages | escalate, evidence names PID-2 and 30/30 |
agent |
| C11 | One critical K 6.8 mmol/L, same units | pass |
rules |
| C12 | New code HB (Hemoglobin); HGB absent |
patch_mapping HB → LOINC 718-7 |
rules |
More cases run only in LIVE: 5 blind cases in cases/blind/ and the specimen pair in cases/pair/.
Interoperability production with BPL (Production.cls), Workflow with role IOA-Reviewers, a custom outbound adapter (ReviewerNotify.cls), lookup table IOA.LabCodeMap, Embedded Python (Py/Ucum.cls), Vector Search (KB/), .NET PEX (pex/), Docker and IPM (https://github.com/AntonYartsev/interop-ops-agent/blob/main/module.xml; zpm "load /path/to/repo", then IOA_DEMO=1 only if you want the demo page).
ORU^R01.2027.1.0AI.109.0 on ARM64 and x64.cases/out/, cases/held/), not a real downstream system. Held messages wait for an engineer. There is no release step.demo.sh --replay marks the 6 agent cases DRIFT (correct 6/12), although the decisions match the expected ones. Use ./run.sh (Docker) for the recorded 12/12.| Command | What it does |
|---|---|
./run.sh |
Start: image load/tag if missing, build, up, compile src/, start the production; last line FINAL STATUS |
./scripts/demo.sh [--manual] [--live | --replay] |
Reset, run the 12 cases, print the score table. --manual leaves patches in the Workflow Inbox |
./scripts/demo.sh --case C01 |
Reset, then only one case (correct 1/1) |
./scripts/demo.sh --reset |
Reset only: cancel open review tasks, purge decisions, reload IOA.LabCodeMap, clear unit factors and batches |
./scripts/check.sh |
OK/FAIL health checks, including the gate self-test InteropOps.Gate.GateTest |
./scripts/check_followup.sh |
C01, approve, then C01 again: the second batch must be rules, 0 LLM calls, passed. Last line FOLLOWUP: OK |
python3 scripts/baseline_rules.py |
Rules-only baseline on the main and blind sets, no IRIS, no LLM |
./scripts/reload.sh |
Re-import and compile src/, restart the production, no rebuild |
./scripts/clean_run.sh --yes |
Timed clean run: down -v, run.sh, demo.sh --replay, check.sh, smoke test; last line CLEAN_RUN: OK score 12/12 ... |
Local URLs (web port 52273):
| Page | URL |
|---|---|
| Production | http://localhost:52273/csp/irisapp/EnsPortal.ProductionConfig.zen?$NAMESPACE=IRISAPP&PRODUCTION=InteropOps.Production |
| Workflow Inbox | http://localhost:52273/csp/irisapp/EnsPortal.WFWorklist.zen?$NAMESPACE=IRISAPP (user IOA_REVIEWER_USER / IOA_REVIEWER_PASSWORD from .env) |
| Demo page | http://localhost:52273/demo/ |

[!NOTE]
Known problems of build 109:
/csp/irisapppages return 404:CreateNamespacein a CPF merge does not create the web application.run.shcreates it (Setup.EnsureDeepSee()). After a plaindocker compose up -d, run./run.shagain.- The image has no
curl, onlywget.<METHOD DOES NOT EXIST>Sharding+2^STUat every start is harmless here.
Public demo host: deploy/.
MIT, see https://github.com/AntonYartsev/interop-ops-agent/blob/main/LICENSE.