
Initial Release
The rules guarantee the safety and the agent explains. An InterSystems AI Hub agent triages laboratory results inside an IRIS Interoperability production: it can raise a result to critical but never lower one the rules call critical, and nobody is notified of a critical result until a person has completed the read-back by repeating the value correctly. The evaluation shows what would happen if the agent decided alone.

zpm "install iris-readback" [PENDING: publication]data/eval/report.mddocker load -i irishealth-community-2026.3.0AI.154.0-docker.tar.gz # then docker tag, if the name differs (build 109: see below)
cp .env.example .env # set READBACK_DEMO_PASSWORD; leave OPENAI_API_KEY empty for replay
src/ops/preflight.sh
docker compose up -d --build
src/ops/verify.sh
Measured: from clone to a working panel in 2 minutes on a clean machine, with the image already loaded (77 s in the last run: image build 56 s with no build cache, start 15 s, verification 1 s; the IPM client and the workflow layer are installed while the image is built, so the start needs no network).
Full steps, with the image rename and the provider key: Required by the organizers. How to run the test battery, run a second stack, reproduce the evaluation and the ablation, free in replay or paid live, and connect an MCP client from your machine: https://github.com/kcedd34/iris-readback/blob/main/REPRODUCE.md.
The evaluation is the strongest evidence of this entry, so it comes first. It runs the whole product, rules, retrieval, the agent with its tools, composition, release guard and budget, over a synthetic dataset of 240 subject cases (30 in each of 8 categories, seed 2026) for two models, and compares the route the product chose with the expected one. The expected decision of every case comes from construction rules written by one author; there was no second annotator. Full detail, with the cases listed, is in data/eval/report.md; the round before the agent had tools is kept in data/eval/report-before-tools.md for comparison. Every evaluation number in this README comes from the blocks below, which are generated from the report and checked against it by the gate.
The safety comes from the deterministic layers: without them an agent that does not see the rule’s verdict would miss and misjudge results, so the design never lets the agent decide alone. What the agent adds is a justification a person can check.
| Metric | gpt-5.4-mini | gpt-5.4 |
|---|---|---|
| Rule-critical results silenced | 0 | 0 |
| Criticals released as non-critical, delta floor off, then on (first round, recorded answers replayed) | 19 to 2 | 7 to 2 |
| Criticals released as non-critical in the current round | 0 | 0 |
| Residual contextual criticals: not detected / sent to a person | 2 / 2 | 2 / 2 |
| Contextual criticals the agent put in read-back without the rule’s verdict, then sent to a person (of 60) | 37 / 23 | 46 / 14 |
| False criticals the agent created without the rule’s verdict | 7 | 0 |
| Contextual criticals ending critical, without tools, then with tools (of 30) | 28 to 28 | 28 to 28 |
| Downgrade attempts held by the floor, without tools, then with tools | 24 to 36 | 18 to 2 |
| Review rate, without tools, then with tools | 17.3% to 27.6% | 15.7% to 28.5% |
| Releases held for a person by the release guard | 22 | 1 |
| Results not clear-cut released as non-critical, guard off, then on (recorded answers replayed) | 10 to 0 | 0 to 0 |
| Review rate without the release guard | 22.6% | 28.3% |
| Tool calls per result | 4.00 | 3.97 |
| Invalid answers | 0.0% | 0.0% |
The same recorded answers, replayed through different arrangements of the layers, over the 240 cases (the replay needs no provider call; the arrangement with the full product reproduces the confusion matrices below, and the file is data/eval/ablation.json). “Contextual criticals” are the criticals the range rule does not catch, so something else has to. In the two arrangements that have no agent, a result is either critical by rule or released, unless the release guard holds it for a person. The agent saw the change tool’s verdict in the recorded round; the rows without the verdict come from a separate paid run per model in which that tool gave the earlier values and the variation but not the verdict or the limit (data/eval/ablation-no-verdict.jsonl). The change rule itself kept running in that run, and the arrangements here simply leave it out of the floor.
| Arrangement | Model | Contextual criticals: read-back / sent to a person / released | Uncertain results released | Rule-critical silenced | False criticals | Review rate |
|---|---|---|---|---|---|---|
| Range rule only | none | 0 / 0 / 60 | 60 of 60 | 0 | 0 | 0.0% |
| Range rule and release guard, no agent | none | 0 / 31 / 29 | 0 of 60 | 0 | 0 | 37.9% |
| Range rule, change rule and release guard, no agent | none | 58 / 2 / 0 | 0 of 60 | 0 | 0 | 25.8% |
| Range rule, guard and agent without the change rule; the agent saw the rule’s verdict | gpt-5.4-mini | 58 / 2 / 0 | 0 of 60 | 0 | 0 | 25.8% |
| Range rule, guard and agent without the change rule; the agent saw the rule’s verdict | gpt-5.4 | 57 / 3 / 0 | 0 of 60 | 0 | 0 | 26.2% |
| The same, but the agent saw the earlier values and the variation, not the verdict | gpt-5.4-mini | 37 / 23 / 0 | 0 of 60 | 0 | 7 | 32.5% |
| The same, but the agent saw the earlier values and the variation, not the verdict | gpt-5.4 | 46 / 14 / 0 | 0 of 60 | 0 | 0 | 31.2% |
| The full product | gpt-5.4-mini | 58 / 2 / 0 | 0 of 60 | 0 | 0 | 25.8% |
| The full product | gpt-5.4 | 58 / 2 / 0 | 0 of 60 | 0 | 0 | 25.8% |
Final decision (route) against the expected decision:
| expected \ decided | CRITICAL | RECOLLECT | NEEDS_REVIEW | NON_CRITICAL | none |
|---|---|---|---|---|---|
| CRITICAL | 148 | 0 | 2 | 0 | 0 |
| RECOLLECT | 0 | 0 | 30 | 0 | 0 |
| NEEDS_REVIEW | 0 | 0 | 30 | 0 | 0 |
| NON_CRITICAL | 0 | 0 | 0 | 30 | 0 |
Agent decision against the expected decision:
| expected \ decided | CRITICAL | RECOLLECT | NEEDS_REVIEW | NON_CRITICAL | none |
|---|---|---|---|---|---|
| CRITICAL | 112 | 12 | 2 | 24 | 0 |
| RECOLLECT | 0 | 10 | 0 | 20 | 0 |
| NEEDS_REVIEW | 0 | 13 | 0 | 17 | 0 |
| NON_CRITICAL | 0 | 0 | 0 | 30 | 0 |
Final decision (route) against the expected decision:
| expected \ decided | CRITICAL | RECOLLECT | NEEDS_REVIEW | NON_CRITICAL | none |
|---|---|---|---|---|---|
| CRITICAL | 148 | 0 | 2 | 0 | 0 |
| RECOLLECT | 0 | 0 | 30 | 0 | 0 |
| NEEDS_REVIEW | 0 | 0 | 30 | 0 | 0 |
| NON_CRITICAL | 0 | 0 | 0 | 30 | 0 |
Agent decision against the expected decision:
| expected \ decided | CRITICAL | RECOLLECT | NEEDS_REVIEW | NON_CRITICAL | none |
|---|---|---|---|---|---|
| CRITICAL | 144 | 1 | 0 | 3 | 2 |
| RECOLLECT | 0 | 30 | 0 | 0 | 0 |
| NEEDS_REVIEW | 0 | 23 | 0 | 7 | 0 |
| NON_CRITICAL | 0 | 0 | 0 | 30 | 0 |
Open http://109.123.244.170/ and press Run demo sequence: a critical potassium stops at read-back, a borderline one goes to a person to review, and in the third the agent tries to lower a critical and the floor holds it. Sign in to open a read-back task with one of the demonstration accounts, all with the same password:
| Account | Can confirm a read-back |
|---|---|
communicator1 |
yes |
communicator2 |
yes |
observer |
no, it can only look |
Password: hVWrjsSnttT8AYIn9acJ
To open a read-back task: after the demo sequence, the critical potassium waits at the read-back station of the flow, in a card marked with the account it is assigned to. Click the card; a side panel opens and asks you to sign in. Use the account the card names (the task goes by rotation to communicator1 or communicator2; observer can look but cannot confirm). Enter whom you told and the value you repeated, and press Confirm the read-back. A value that does not match is refused: the task stays open and the attempt is counted and shown in the panel; only a matching value closes the task and the alert.
The demo runs in replay mode against a real IRIS instance: the decisions are the recorded answers of a real run of the agent, so no key is needed and no provider is called. The data is synthetic. The demo is plain HTTP, and it is reset every hour, on the hour; it is unavailable for about half a minute while it resets.
The demonstration accounts are deliberately broader than a production deployment would use, because the credential is shared and the demo has to show the escalation working. They hold %DB_USER on the USER database, and the two communicator accounts also hold %EnsRole_AlertAdministrator, so that they can close alerts they do not own: the read-back task goes by rotation to either communicator, so the person who confirms is not always the owner of the alert. None of them holds %All, %Manager or %Admin. The demo builds its messages with Embedded Python, and the %Service_EmbeddedPython resource that needs belongs to the web application, through a role that is matched only while the application serves a request, not to any account. In a production deployment each of these would be a role specific to the Readback, and the alert would be closed in a system context on behalf of the person who confirmed.
The exposure is minimal: only port 80 is open, and the proxy answers 404 for every native IRIS route (/csp/sys, /api/admin, /api/mgmnt and the Atelier API).
%All, %Manager or %Admin.observer account, which can only look: the proxy adds that identity to the reads it forwards, and a person who wants to confirm a read-back signs in with a communicator account.The product is built on the InterSystems IRIS for Health Community image with the AI Hub, Early Access. It runs on two builds of that image: build 154, tagged irishealth-community:2026.3.0AI.154.0 (the default), and build 109, tagged irishealth-community:2027.1.0AI.109.0. The image comes from the Early Access portal. It is not redistributed here, and nothing in this project pushes it to a registry.
Tested on both: the full battery of 32 steps (tests/e2e/triage_check.sh) passes on build 154 and on build 109, each in a stack of its own, and the evaluation in replay gives the shipped report on both. One difference between the builds matters: build 109 renamed the RESOURCE parameter of %AI.MCP.Service to SECURITYRESOURCE and no longer reads the old one, so a service written for 154 stops checking its resource; the product sets both and every tool checks the resource itself.
The archive downloaded from the portal is named like irishealth-community-2026.3.0AI.154.0-docker.tar.gz (or ...109.0... for build 109). Load the one the portal offers you:
docker load -i irishealth-community-2026.3.0AI.154.0-docker.tar.gz
docker images | grep irishealth
docker tag <name-printed-by-docker-load>:<tag> irishealth-community:2026.3.0AI.154.0
src/ops/preflight.sh
For build 109, load the 109 archive, tag it irishealth-community:2027.1.0AI.109.0, and choose it in .env (the default is build 154):
docker tag <name-printed-by-docker-load>:<tag> irishealth-community:2027.1.0AI.109.0
echo 'READBACK_BASE_IMAGE=irishealth-community:2027.1.0AI.109.0' >> .env
src/ops/preflight.sh
The docker tag line is only needed when the loaded image does not already have that name. src/ops/preflight.sh checks that the chosen tag exists, says which build it found, and, if it does not exist, prints the load and tag steps for both builds (and tells you when the other build is the one that is loaded).
The agent calls OpenAI, with the key in the environment variable OPENAI_API_KEY. The default model is gpt-5.4. gpt-5.4-mini is the cheaper option: it costs a fraction of the default per result and answers faster, but its agent tries to lower criticals more often (the floor holds every attempt) and the release guard holds more of its releases. Its cost, latency and errors are measured next to the default’s in For judges. The key is read from the environment only, and it is never printed, logged or written to a file.
Leave OPENAI_API_KEY empty and the product runs in replay mode, with the recorded answers of a real run of the whole evaluation set, tool calls included: everything works with no key and no cost, and the answers are the ones the agent gave. Set it to run live. The cost per thousand results is in For judges.
cp .env.example .env
# edit .env: set READBACK_DEMO_PASSWORD to a password of your choice; leave OPENAI_API_KEY empty to use replay
src/ops/preflight.sh
docker compose up -d --build
src/ops/verify.sh
curl -s http://127.0.0.1:8080/readback/api/status
src/ops/preflight.sh checks that the image is loaded and that the password reaches the product as you wrote it. Any printable password works: quotes, an apostrophe, #, !, a backslash, &, ; and spaces inside were all tested end to end. Three things are refused with a message, because they would change the password on the way: a single $ in .env (compose reads it as a variable, so write $$ for a literal one), a # after a space in .env (compose reads it as a comment), and a space at either end. src/ops/verify.sh waits until the production is running and exits non-zero if it is not; it then checks that the task list layer is in the image and exits non-zero if it is not. Open http://127.0.0.1:8080 for the panel. The build downloads the small local embedding model and installs the IPM client and the workflow layer, so it needs network that one time; the running container needs none. The whole battery of checks, from a clean stack, is tests/e2e/triage_check.sh.
In the container only the start script (src/ops/bootstrap.sh) starts the production, and it does so after the Java gateway of the unit conversion component accepts a connection; IRIS does not start it by itself. The script runs at every start of the container, so docker compose restart iris brings the production back with no other step, and if the component cannot be set up the container stops with a message instead of starting half a production. src/ops/verify.sh says “the production is not running” as soon as the container stops or the start reports a failure, and the event log of a start holds no error and no warning (tests/e2e/start_check.sh checks it).
The product can also be installed as an IPM package into an instance that has the AI Hub: with the repository on disk, zpm "load /path/to/iris-readback" installs it, builds the unit conversion component and starts the production. Policy retrieval stays unavailable on such an instance until the embedding model is provided, as the image does; everything else works. The package sets the production to start with the instance, so on an IPM installation too the production is running again after the instance restarts, with no other step (tests/e2e/ipm_check.sh restarts the instance three times and checks it).
This is the architecture of the product, the Interoperability components it really uses:
The product exposes a read-only MCP server: the platform’s own %AI.MCP.Service (src/Readback/MCP/Service.cls) over four tools (src/Readback/MCP/Tools.cls): list_pending_criticals, explain_decision, read_evaluation and read_ablation. They follow the rules of the public feed: read only, no patient identifier in any answer, and the explanation of a decision is built from its trace record, with no model asked.
The read-back is deliberately not exposed. No tool confirms a read-back, closes an alert or completes a task. An AI assistant never closes a critical result: the read-back is the one act a person must do, by repeating the value, and a tool that could do it would take that act away from the person. This is a security decision, not a gap.
The access is closed. The service is the web application /readback/mcp on the management port, which the public proxy does not route. A caller needs the resource Readback_MCP, held by the role ReadbackMcp, and only the read-only demonstration account observer has that role.
To connect a client, run the platform’s gateway, iris-mcp-server, which the image ships, inside the container with this configuration in /tmp/mcp.toml (the password is the demonstration password of the observer account):
[[iris]]
name = "readback"
[iris.server]
host = "127.0.0.1"
port = 1972
username = "observer"
password = "<the demonstration password>"
[[iris.endpoints]]
path = "/readback/mcp"
docker compose exec iris /usr/irissys/bin/iris-mcp-server -c /tmp/mcp.toml -t http://127.0.0.1:8000 run
Any MCP client that speaks the streamable HTTP transport connects to http://127.0.0.1:8000/mcp with the header Authorization: Basic <observer:password in base64>; the tools are listed as readback_mcp_list_pending_criticals, readback_mcp_explain_decision, readback_mcp_read_evaluation and readback_mcp_read_ablation, and the gateway lists none to a caller without credentials. The client verified here is the MCP Inspector 2.13.0 in command-line mode:
npx @modelcontextprotocol/[email protected] --cli http://127.0.0.1:8000/mcp --transport http --header "Authorization: Basic <base64>" --method tools/list
The web application of the service is created with the type 18, the REST type plus the “MCP Server” bit (16). An application of type 2 only works over plain HTTP but the gateway answers it with a 404 and reports that it “did not identify itself” (docs/probes.md, Probe 17).
What was tested: a complete session with the MCP Inspector through the gateway (tests/e2e/mcp_client_check.sh): the four tools are listed and no other, the pending criticals and the evaluation are read, a call to a tool that would confirm the read-back is answered tool_not_found, and a caller without credentials and an account without the resource are listed no tool; the tools in the platform’s own tool manager (tests/e2e/mcp_check.py, tests/objectscript/Readback/Test/McpToolsTest.cls, tests/python/test_mcp_source.py): exactly the four read tools, none of them confirms anything, no table of the product changes when they are called and no answer carries a patient identifier; and over plain HTTP, the service serves nobody directly (401 without credentials, 404 with them: an application of the MCP Server type is reached through the gateway), an account without the Readback_MCP resource is listed no tool through the gateway, and the public proxy does not route the service.
| Bonus | Where it is used | File |
|---|---|---|
| PEX (Production Extension) | Unit conversion with the UCUM library, in Java, before the rules run | java/src/com/readback/units/UnitsOperation.java |
| Workflow Engine | The read-back and the human review are Workflow tasks, assigned by rotation to a role | src/Readback/Workflow/ReadbackTask.cls |
| Custom outbound adapter | The delivery-and-receipt channel of the critical-result alert: an outbound adapter with its own operation, which keeps the receipt apart from the confirmation | src/Readback/Notify/Pager.cls |
| Embedded Python | The demo’s synthetic HL7 messages are generated inside IRIS by methods with [ Language = python ], running the same Python generator that builds the evaluation dataset outside IRIS. “Send result” and “Run demo sequence” call it for every submission, with a case id of its own. Embedded Python is not used by the rules or the agent |
src/Readback/Demo/Generator.cls, called from src/Readback/API/Flow.cls |
| Vector Search | Policy passages and similar confirmed cases, found by meaning with the IRIS vector store and local embeddings | src/Readback/Retrieval/Engine.cls |
| Docker | The image build and the compose stack, including the reverse proxy of the demo | Dockerfile |
| IPM | The package descriptor; zpm "load" installs it from the repository, publication pending |
module.xml |
| MCP server | A read-only %AI.MCP.Service with four tools; it refuses to confirm a read-back, on purpose |
src/Readback/MCP/Service.cls |
| Bug reports | Two bugs of the AI Hub filed in its bug tracker, with reproductions that do not use this product: issue 18 (the agent loop has no default bound) and issue 19 (the embedding model is described as bundled and is downloaded); the other reports are listed in Feedback on AI Hub EAP | README.md |
| Online demo | http://109.123.244.170/ in replay mode, behind a proxy that forwards only the Readback paths | src/ops/proxy/nginx.conf.template |
| Community Opportunity idea DPI-I-689 | Traceability of an LLM pipeline over Interoperability: every decision, tool call and human step is recorded | src/Readback/Trace/Decision.cls |
Observed on the Early Access build 154 while building this product, and checked again on build 109. Each item says whether it worked, needed a workaround here, or is a candidate for a report. The five candidates below were checked against the issues of intersystems-community/ai-hub-eap for duplicates and reported from this project; the argument types in policies were already reported by someone else. The two that are bugs, the loop and the embedding class, were also filed in intersystems-community/ai-hub-eap-bugreports, the tracker for bugs found in AI Hub, with their reproductions, after a search for duplicates there.
response_format travels with the tools in every round, on the wire and on the real provider, so structured output and tools work together.ReadOnly requirement on a tool reaches the authorization policy as metadata, and a tool without it never ran.max_tokens is rejected by the provider for the model used, which wants max_completion_tokens; the session configuration does not take it directly and it goes through extra_params.unknown, never zero, for a model without a price.RESOURCE became SECURITYRESOURCE in %AI.MCP.Service, and the old parameter is ignored without a warning. On build 109 the class does no authorization of its own unless SECURITYRESOURCE is set or OnAuthenticate() is overridden (its description says so). The read-only MCP server of this product set RESOURCE, so on 109 an authenticated account without the resource listed and called the tools; the step of the battery that checks this account caught it. The product now sets both parameters and each tool checks the resource itself, and a test calls the tools directly as an account without it. A request without credentials is answered 401 on 154 and 404 on 109; both refuse. Reported as issue 47, with a reproduction that uses none of this product’s code.Each report has a reproduction that does not use this product: the stock build 154 image and a small mock provider, with no key.
Create() downloads it and fails offline. Also filed in the contest’s bug tracker as issue 19; the discussion is in issue 44.%AI.MCP.Service web application without the “MCP Server” type bit is answered 404 by iris-mcp-server, which reports that the endpoint “did not identify itself”; with type 18 it works. Found while building the read-only MCP server of this product. Related to issue 21.This entry also answers the Community Opportunity idea DPI-I-689, whose status on the ideas portal is Community Opportunity. The idea asks for traceability of an LLM pipeline over Interoperability. The product demonstrates exactly that on a real clinical pipeline: every decision records the rule that fired, the agent’s inputs and answer, each tool call it made and what the call returned, the model, the prompt version, tokens, estimated cost and latency, and the outcome of the human step, all linked to the production’s own session, and the evaluation turns those records into measured cost and latency. The Visual Trace session of a decision shows the result, the agent’s step, the task and the alerts; the tool calls, in order and with their time, are in the product’s own trace and on the Decisions screen, which points to that session, and the policy search, which calls another operation, also shows in the Visual Trace. It covers the essence of the idea without reimplementing a general LLM observability product such as Langfuse.
What an LLM tracing tool records, and where each item is in Readback (the decision is read through GET /readback/api/decisions/{controlId}, the one record behind every row):
| What an LLM tracing tool records | Where it is in Readback |
|---|---|
| A session per request | sessionId, the session of the production’s own message trace, shown by the Decisions screen with its link to the Visual Trace; table Readback.Trace.Decision |
| Model and prompt version | model, provider, promptVersion and mode of the decision; Readback.Trace.Decision |
| Tokens, cost and latency | inputTokens, outputTokens, costUsd and latencyMs of the decision; cost per thousand results and percentiles in GET /readback/api/indicators; Readback.Trace.Indicators |
| Tool calls | toolCalls of the decision, in order, with arguments, result, status and time; table Readback.Trace.ToolCall; the policy search also appears in the Visual Trace |
| Retrieval | passageIds, caseLabels, indexVersion and retrievalNote of the decision; Readback.Retrieval.Engine |
| Errors and policy refusals | failureKind, failureReason, policyRefusal and evidenceCheck of the decision; a refused or failed call has its status in toolCalls |
| Human outcome | confirmedBy, readbackMatch, reviewedBy and taskId of the decision; each attempt in Readback.Trace.ReadbackAttempt; the history of each alert in GET /readback/api/alerts; GET /readback/api/taskview/{decisionId} |
The video is the main demonstration: it shows a result arriving, the agent deciding, a critical stopping at the read-back, a person repeating the value and the alert escalating when nobody does. In short:
A lab result enters the production. The rules decide first and fix a floor: a value over the critical limit, or a sharp change against the patient’s earlier results, is critical whatever the agent says. The agent then investigates the result through four read-only tools, the earlier results, the delta assessment, the laboratory policy and the sample quality, and answers in a strict format; it may raise the severity, and a result it cannot decide goes to a person. A release guard after the answer lets a result go automatically only when the floor is zero, the sample is good and the value is not near a limit.
A critical result becomes a read-back task for a person. The task shows the value, the rule that fired and the agent’s rationale and evidence, labelled as the agent’s. The person must call the clinician and repeat the value; only a value that matches closes the task and the alert. If nobody confirms in time, the alert escalates to the next person.
The panel shows the flow live and the queue of critical alerts with the history of each alert. The same flow runs from recordings with no key, which is how the demo runs.
| Library | Version | Licence |
|---|---|---|
| React, React DOM | 19.3.1 | MIT |
| @xyflow/react (React Flow) | 12.12.0 | MIT |
| Vite | 7.3.7 | MIT |
| TypeScript | 7.0.2 | Apache-2.0 |
| Vitest, Testing Library, jsdom | 5.0.5, 16.3.3, 30.1.3 | MIT |
| Playwright | 1.64.1 | Apache-2.0 |
| nginx (the reverse proxy of the demo) | 1.30.6 | BSD-2-Clause |
UCUM for Java (org.fhir:ucum) |
1.0.9 | BSD-3-Clause |
The product runs on InterSystems IRIS for Health Community with the AI Hub (Early Access build 154); the REST layer of the Workflow Engine comes from the community package workflow-rest. The frontend loads nothing from a network at run time, and its initial JavaScript and CSS are under 150 KB compressed.
Carlos Eduardo Dias Duarte. Released under the MIT licence, see LICENSE.