Agent-first, PID-safe research image loader for hospital PACS.
CLI tool: rad-loader
AI agents are becoming the primary interface for hospital IT tasks. A radiologist using Claude Code can query PACS, manage research data, and automate HL7 workflows faster than navigating GUIs or writing one-off scripts. Agent-driven workflows are not a future possibility — they are already more efficient than the alternatives for many tasks.
But there is a fundamental problem: the agent's context window is a PHI leak vector.
When an AI agent runs dcm4che or reads DICOM headers, patient names, IDs, and birth dates flow into the agent's context. That context is sent to cloud servers (Anthropic, OpenAI, etc.). This is a privacy violation under GDPR, HIPAA, and most hospital data protection policies — regardless of how the cloud provider handles the data.
Existing DICOM tools were not designed with this threat model in mind. They predate LLM agents and treat PHI as something the user manages. They output unstructured text that agents must parse. They have no concept of an agent's context window as an attack surface.
pacs-agent addresses both problems:
-
PHI never enters the agent's context. The tool acts as a privacy gateway between the agent and hospital systems. Input is accession numbers (not patient identifiers). Output uses pseudonymized case IDs. Anonymization is mandatory and happens inside the tool — there is no code path that exposes PHI to the caller.
-
The tool is designed for agent operation. Structured JSON output. Machine-readable errors. Idempotent operations that are safe to retry. Built-in verification so the agent can assess data quality programmatically instead of visually.
Cloud Hospital network
┌─────────────┐ ┌─────────────────────────────────┐
│ AI Agent │ │ │
│ (LLM) │──accession──▶ │ pacs-agent ──▶ PACS │
│ │◀──case ID─── │ (PHI gateway) │
│ │ │ │
│ Never sees: │ │ Handles internally: │
│ - names │ │ - patient names │
│ - IDs │ │ - patient IDs │
│ - DOB │ │ - birth dates │
│ - any PHI │ │ - all PHI tags │
└─────────────┘ └─────────────────────────────────┘
pacs-agent is an implementation of a broader pattern. Any tool that lets an AI agent interact with hospital systems should follow these principles:
- No PHI in agent context — the tool handles PHI internally and returns only de-identified or coded data. The agent never needs to see a patient name to do its job.
- Structured, machine-readable output — JSON by default, not human text that agents have to parse with regex.
- Idempotent operations — agents retry on failure. Tools must handle duplicate requests gracefully (already-loaded studies are skipped, not duplicated).
- Built-in verification — agents cannot visually inspect results. The tool must provide programmatic quality checks (outlier detection, completeness verification).
- Audit trail — every action is logged with who, what, when, and outcome. Non-negotiable for clinical data.
- Accession-based addressing — accession numbers are the natural unit of work for research. They identify studies without revealing patient identity.
This pattern extends beyond PACS. HL7 message routing, RIS queries, report retrieval, DICOM routing — anywhere an agent touches hospital data, the same architecture applies: a local tool that acts as a PHI firewall, exposing only what the agent needs to see.
Researcher / AI Agent
┌──────────────────┐
│ rad-loader │
│ load project AC │
└────────┬─────────┘
│
▼
┌──────────────────────────────────────┐
│ 1. C-FIND (accession → study UID) │
│ ↓ │
│ 2. Start temporary SCP (port 9012) │
│ ↓ │
│ 3. C-MOVE (study UID → our SCP) │
│ ↓ │
│ PACS sends C-STORE ──┐ │
│ ↓ │ │
│ 4. Receive + anonymize │ ◀── mandatory, no bypass
│ ↓ │ │
│ 5. Save .dcm files │ │
│ ↓ │
│ 6. Update key.csv + audit log │
│ ↓ │
│ 7. Shut down SCP │
└──────────────────┬───────────────────┘
│ DICOM
┌─────────▼──────────┐
│ Hospital PACS │
│ (any vendor) │
└────────────────────┘
Key design choices:
- Allowlist anonymization — only explicitly listed DICOM tags survive. Everything else is deleted. Safer than a blocklist, where new or unexpected tags could slip through.
- No patient identifiers in I/O — input is accession numbers only, output uses case IDs (case0001, case0002, ...).
- Idempotent — already-loaded studies are skipped automatically. Safe to re-run.
- Audit trail — every load is recorded in a SQLite database.
git clone https://github.com/mijuny/pacs-agent.git
cd pacs-agent
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"cp config/example.yaml config/config.yaml
# Edit config/config.yaml with your PACS connection detailsYour PACS administrator needs to:
- Register your AE title (e.g.
MY-LOADER) on the PACS - Allow your host to connect
- Open the SCP port (default 9012) in your firewall for incoming C-STORE from PACS
# Test PACS connection
rad-loader echo
# Query a study (no images downloaded)
rad-loader query VAR9946804
# Load a study (download, anonymize, save)
rad-loader load myproject VAR9946804
# Load from file (one accession per line)
rad-loader load myproject --file accessions.txt
# Dry run (query only)
rad-loader load myproject --file accessions.txt --dry-run
# Check project status (includes outlier detection)
rad-loader status myproject
# View audit log
rad-loader audit myprojectrad-loader [--config CONFIG] [--human] [-v] {echo,query,load,status,audit}
Global flags (must come BEFORE the subcommand):
| Flag | Description |
|---|---|
--config PATH |
YAML config file (default: config/config.yaml) |
--human |
Human-readable output instead of JSON |
-v |
Verbose logging (DICOM-level traffic) |
echo — Test PACS connection (C-ECHO)
rad-loader echoquery — Look up a study by accession number (C-FIND, no download)
rad-loader query <ACCESSION>load — Download, anonymize, and save studies
rad-loader load <PROJECT> <AC1> [AC2 ...]
rad-loader load <PROJECT> --file <ACCESSION_FILE>
rad-loader load <PROJECT> --file <ACCESSION_FILE> --dry-runstatus — Project statistics with outlier detection
rad-loader status <PROJECT>audit — View audit log
rad-loader audit <PROJECT> [--last N]
rad-loader audit --all [--last N]Every load command returns a verification field:
{
"verification": {
"ok": false,
"total_requested": 10,
"loaded": 7,
"skipped": 1,
"failed": 2,
"not_found": 1,
"warnings": ["AC001 (case0003): only 2 images (unusually low)"]
}
}The status command compares cases within a project:
{
"outliers": {
"ok": false,
"median_series": 5,
"median_images": 450,
"warnings": [
"case0003: 1 series vs median 5 — possibly incomplete study",
"case0009: modality CR differs from majority MR"
]
}
}All loads are recorded in a SQLite database with: Unix user, accession numbers, project name, timestamp, duration, and result.
pacs-agent uses an allowlist approach: only DICOM tags explicitly listed in the allowlist survive anonymization. All other tags — including unknown, private, and vendor-specific tags — are deleted. This is safer than a blocklist approach, where new or unexpected tags could slip through.
| Field | DICOM Tag |
|---|---|
| PatientName | (0010,0010) |
| PatientID | (0010,0020) |
| PatientBirthDate | (0010,0030) |
| OtherPatientIDs | (0010,1000) |
| OtherPatientNames | (0010,1001) |
| IssuerOfPatientID | (0010,0021) |
| PatientAddress | (0010,1040) |
| PatientTelephoneNumbers | (0010,2154) |
| AdditionalPatientHistory | (0010,21B0) |
| PatientComments | (0010,4000) |
| ReferringPhysicianName | (0008,0090) |
| PerformingPhysicianName | (0008,1050) |
| OperatorsName | (0008,1070) |
| RequestingPhysician | (0032,1032) |
| InstitutionName | (0008,0080) |
| InstitutionAddress | (0008,0081) |
| RequestAttributesSequence | (0040,0275) |
| All private tags | Odd group numbers |
| All unknown tags | Not on allowlist |
| Category | Examples |
|---|---|
| Identifiers (non-PID) | AccessionNumber, StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID |
| Study metadata | StudyDate, Modality, StudyDescription, SeriesDescription |
| Demographics | PatientSex, PatientAge, PatientSize, PatientWeight |
| MR parameters | RepetitionTime, EchoTime, FlipAngle, MagneticFieldStrength, SliceThickness, DiffusionBValue, ... |
| CT parameters | KVP, ExposureTime, XRayTubeCurrent, ConvolutionKernel, ... |
| General acquisition | ProtocolName, BodyPartExamined, ContrastBolusAgent, PatientPosition |
| Pixel description | Rows, Columns, PixelSpacing, BitsAllocated, WindowCenter/Width, RescaleSlope/Intercept |
| Spatial geometry | ImagePositionPatient, ImageOrientationPatient, SliceLocation, FrameOfReferenceUID |
| Equipment | Manufacturer, ManufacturerModelName, SoftwareVersions, DeviceSerialNumber |
| Pixel data | PixelData (the actual image) |
| Field | Value |
|---|---|
| PatientName | Case ID (e.g. case0001) |
| PatientID | Case ID |
| PatientIdentityRemoved | YES |
| DeidentificationMethod | pacs-agent allowlist v1 |
- Burned-in annotations in pixel data are NOT removed. Review images manually.
- Structured Reports (SR) are not anonymized — they are skipped.
- Single-threaded: one study at a time via C-MOVE.
- Temporary SCP: the receive port is open only during the load.
pacs-agent is designed to be operated by AI agents (Claude Code, etc.). All commands output structured JSON by default.
- Add the CLAUDE.md template to your project — it teaches the agent all available commands, flag ordering, and the verification workflow.
- The agent uses accession numbers as input (provided by the researcher).
- The agent receives JSON responses with case IDs, image counts, and verification results — never patient data.
- The agent can run the full workflow: query, load, verify, report outliers — without any PHI ever entering its context.
Plain text, one accession number per line. Empty lines and # comments are allowed:
# Brain hemorrhage study, spring 2026
VAR9946804
VAR9946805
# Control patients
VAR9946810
VAR9946811
See config/example.yaml:
pacs:
host: "pacs.example.com" # PACS host IP or hostname
port: 104 # PACS port (standard DICOM)
ae_title: "YOUR_PACS" # PACS AE title (called AE)
scp:
ae_title: "MY-LOADER" # Our AE title (calling AE / SCP)
port: 9012 # Port for incoming C-STORE
output:
base_dir: "/data/research" # Base output directoryCopy to config/config.yaml and fill in your values. All .yaml files except example.yaml are gitignored.
<output_base_dir>/
├── audit.db # Global audit database (SQLite)
├── <project>/
│ ├── key.csv # case_id,accession,study_date,modality,description,series_count,image_count
│ ├── load.json # Machine-readable load summary
│ ├── case0001/
│ │ ├── series01/*.dcm
│ │ ├── series02/*.dcm
│ │ └── ...
│ └── case0002/
│ └── ...
pip install -e ".[dev]"
pytest tests/ -vTests that require DICOM sample files are automatically skipped in CI. To run the full test suite locally, place DICOM WG-04 reference files in ~/projects/dicom-test-files/data/WG04/REF/ as .zst archives.
A researcher-oriented guide in Finnish is available at GUIDE_FI.md.
Contributions are welcome. Please open an issue first to discuss what you'd like to change.
If you use pacs-agent in your research, please cite:
@software{pacs_agent,
author = {Nyman, Mikko},
title = {pacs-agent: Agent-first, PID-safe research image loader for hospital PACS},
url = {https://github.com/mijuny/pacs-agent},
year = {2026}
}This work is licensed under CC BY-NC 4.0. You are free to use and adapt it for non-commercial purposes with attribution.