Iterative agent that maps arbitrary spreadsheets to the
nmdc-submission-schema.
A deterministic pre-pass drafts a mapping with placeholders for anything it
cannot resolve; an iterative agent loop then resolves each placeholder with
evidence (schema slot lookup, ontology resolution via OAK/runoak) or
explicitly refuses. Output is both a schema-conformant artifact and a curation
report auditing every placeholder outcome.
See IMPLEMENTATION_PLAN.md for the full implementation plan (a NiceGUI web app + iterative agent, rebuilt fresh from OpenScientist patterns). The earlier docs/SPREADSHEET_MAPPING_AGENT_PLAN.md is superseded.
Scientists submit environmental/microbiome data as spreadsheets (TSV/CSV/Excel). A database called NMDC (National Microbiome Data Collaborative) requires that data to follow a strict schema — rules about what columns mean, what values are legal, and which controlled vocabularies (ontologies) to use.
Manually reconciling a messy spreadsheet against that schema is tedious expert work. Harmonizer automates it using an AI agent. Every column it cannot confidently map becomes a placeholder that either gets resolved with evidence or explicitly refused.
In short: spreadsheet in → (1) validated schema-conformant file + (2) a "curation report" explaining every decision, out.
Spreadsheet upload
│
▼
┌─────────────────────────┐
│ PHASE 1: Pre-Pass │ Deterministic, fast, no AI.
│ (job/setup.py) │ Pure Python + pandas.
│ │
│ • Read the spreadsheet │
│ • Guess which schema │
│ "interface" it is │ (e.g. SoilInterface)
│ • Fuzzy-match each │
│ column to a schema │
│ "slot" w/ confidence │
│ • Anything unsure → │
│ a PLACEHOLDER │
└───────────┬─────────────┘
│ writes 3 JSON "sidecar" files + DB rows
▼
┌─────────────────────────┐
│ PHASE 2: Agent Loop │ Iterative, uses the LLM.
│ (orchestrator/loop.py) │ Runs on a background thread.
│ │
│ repeat up to N times: │
│ • Show agent the │
│ remaining │
│ placeholders │
│ • Agent uses TOOLS to │
│ look up schema, │
│ resolve ontology │
│ terms, validate │
│ • Agent RESOLVES or │
│ REFUSES each one │
│ • Stop early when none │
│ remain │
└───────────┬─────────────┘
│
▼
Two outputs: mapped_output.json + curation_report.json
Key design philosophy: do the cheap deterministic work first, and only spend the expensive/unpredictable AI on the hard leftovers. The pre-pass is guaranteed and reproducible; the agent only touches what the pre-pass flagged as uncertain.
There are four cooperating layers/processes. The important thing is who runs what:
- Framework: NiceGUI — a Python library for building reactive web UIs without JavaScript. It runs on top of FastAPI.
- File:
src/harmonizer/web/app.py - Pages:
/— list of all jobs with live status badges/new— upload a spreadsheet, add optional "study context," set max iterations/job/<id>— live progress of one job; download buttons when done/schema— browse the NMDC schema interfaces and their slots
- The page polls the database every 1.5 seconds to show live progress — a simple alternative to websockets.
- File:
src/harmonizer/job/manager.py - Holds a small thread pool (2 workers by default). When you upload a file it runs the pre-pass synchronously (so you wait a moment), then submits the slow agent loop to a background worker and immediately redirects you to the job page. This split keeps the UI responsive.
The AI agent and the tools it uses are two separate processes that communicate over MCP (Model Context Protocol).
- The agent (
src/harmonizer/agent/claude_code_agent.py) wraps the Claude Agent SDK. It is the thing that "thinks." - The tools server (
src/harmonizer_tools/) is a separate subprocess exposing a fixed menu of functions the agent is allowed to call:list_interfaces(),get_slots(interface)— ask the schema what's validvalidate_value(slot, value)— check a value against schema rules (enums, patterns)record_mapping(...)— commit a resolved mapping to the ledgerleave_placeholder(...)— explicitly refuse, with a reasonexecute_code(...)— a sandboxed Python cell (pandas + ontology lookups viarunoak)
Why separate them? Safety and control. The LLM can only call these vetted tools; every action it takes is auditable. The LLM proposes, deterministic code disposes.
- File:
src/harmonizer/schema/submission_schema.py - Thin wrappers over
linkml-runtime, which reads the NMDC schema (distributed as YAML via thenmdc-submission-schemapip package). Only the schema YAML is reused; the Python wrappers are original.
- A relational database (
database/models.py, SQLAlchemy): two models —Job(one row per upload, tablejobs) andPlaceholderRow(one row per uncertain column, tableplaceholder_rows). Statuses likePENDING/RUNNING/COMPLETEDand outcomes likeRESOLVED/LEFT_PLACEHOLDER/VALIDATOR_REJECTEDlive here. The engine is chosen byHARMONIZER_DATABASE_URL(database/session.py): it defaults to a local SQLite file (sqlite:///harmonizer.db) forjust web, and the Docker Compose stack points it at a first-class Postgres service instead (hencepsycopgin the dependencies). Same schema either way; no ORM changes needed. - JSON "sidecar" files in each job directory (
draft_mapping.json,curation_inputs.json,curation_report.json). The database and these files are kept in sync. The loop has a "freshness guard" that checks the curation report's modified-time advances each turn, to confirm the agent is doing work.
- Folder:
skills/workflow/*.md— plain markdown encoding expert methodology (resolving an environmental triad, resolving a taxon via NCBITaxon, curation governance rules). Copied into each job's.claude/skills/so the agent reads them as guidance. Knowledge as prose, not code.
- A hierarchy of Dockerfiles (
base → web, andbase → executor → agent) lets each mapping job run inside its own throwaway container with prefetched offline ontologies. TheSettingsdefault is off (use_container_isolation=False), so localjust webruns the loop in-process. The Docker Compose stack instead turns isolation on (HARMONIZER_USE_CONTAINER_ISOLATION=1) and adds a first-class Postgres service the web process and every sibling job container share. For local learning, skip Docker entirely.
harmonizer/
├── pyproject.toml # dependencies (managed by `uv`)
├── justfile # task shortcuts (like a Makefile)
├── README.md
├── src/
│ ├── harmonizer/ # the main application
│ │ ├── web/ # NiceGUI browser UI ← entry point
│ │ ├── job/ # setup.py (pre-pass) + manager.py (thread pool)
│ │ ├── orchestrator/ # loop.py (agent iteration) + prompts.py
│ │ ├── agent/ # Claude Agent SDK wrapper + skills loader
│ │ ├── schema/ # linkml wrappers over NMDC schema
│ │ ├── state/ # MappingState (placeholder tracking)
│ │ ├── providers/ # anthropic/cborg backend dispatch (factory.py)
│ │ ├── database/ # SQLAlchemy models + session (SQLite or Postgres)
│ │ ├── job_container/ # optional Docker-per-job runner
│ │ └── settings.py # config from env vars (HARMONIZER_* prefix)
│ └── harmonizer_tools/ # the MCP tools server (separate process)
├── skills/workflow/*.md # domain methodology the agent reads
├── Dockerfile.* # optional containerized execution
└── evaluations/ # phase-by-phase test reports
The justfile is the cheat sheet — it lists every runnable action. just is a
task runner (like make).
Docker is not required for local experimentation.
just install # sync dependencies (== uv sync)
just test # (optional) run the test suite
just web # start the web UI (== uv run python -m harmonizer.web)Then open http://127.0.0.1:8080, click New, upload a spreadsheet (TSV/CSV/XLSX), and submit.
The system has two modes depending on whether you provide an LLM key:
- Without an API key — the deterministic pre-pass and the whole UI work fine. You can upload a file, watch it guess an interface, and see placeholders created. Best way to understand the plumbing without spending money. The agent loop cannot resolve placeholders (no brain to call).
- With an API key — the agent loop actually resolves/refuses placeholders:
export ANTHROPIC_API_KEY=sk-...
just webThe backend is selected with HARMONIZER_PROVIDER (default anthropic). Two
providers ship today:
# Direct Anthropic (default) — uses the x-api-key scheme
export ANTHROPIC_API_KEY=sk-ant-...
# CBORG (LBNL gateway) — uses Claude Code's bearer scheme under the hood
export HARMONIZER_PROVIDER=cborg
export CBORG_API_KEY=... # request one at https://cborg.lbl.gov/api_request
# optional overrides:
export HARMONIZER_MODEL=claude-opus-4-8 # CBORG default
export HARMONIZER_CBORG_BASE_URL=https://api-local.cborg.lbl.gov # LBL network onlyUnder the hood the CBORG provider points the Claude Code CLI at
ANTHROPIC_BASE_URL with an ANTHROPIC_AUTH_TOKEN bearer token, rather than an
ANTHROPIC_API_KEY. See cborg.lbl.gov for models.
export ANTHROPIC_API_KEY=sk-... # enables the agent
export HARMONIZER_MAX_ITERATIONS=10 # agent loop cap
export HARMONIZER_JOBS_ROOT=jobs # where job dirs go
export HARMONIZER_DATABASE_URL=sqlite:///harmonizer.db # or postgresql+psycopg://…
export HARMONIZER_USE_CONTAINER_ISOLATION=0 # keep Docker offHARMONIZER_DATABASE_URL accepts any SQLAlchemy URL: the SQLite file above for
local dev, or postgresql+psycopg://user:pass@host:5432/db to use Postgres (what
the Docker Compose stack sets automatically). Container-isolation deployments also
honor HARMONIZER_JOB_IMAGE, HARMONIZER_HOST_PROJECT_DIR,
HARMONIZER_CONTAINER_APP_DIR, and HARMONIZER_AGENT_NETWORK (see
docker-compose.yml).
The justfile lets you run each stage in isolation:
just prepass path/to/your.tsv # run ONLY the deterministic pre-pass, then
# inspect the generated JSON sidecars
just loop <job-dir> # run ONLY the agent loop on a prepared job
just tools # start the MCP tools server by hand (smoke test)just docker-build # build base -> web, base -> executor -> agent
just docker-up # start Postgres + the web app; UI at http://localhost:8080The Compose stack (docker-compose.yml) runs Postgres 18 as a first-class
service and points HARMONIZER_DATABASE_URL at it
(postgresql+psycopg://…@postgres:5432/…), rather than the local SQLite file.
It also enables per-job container isolation by default, launching sibling job
containers via the mounted Docker socket.
- Run
just webwith no API key, upload a small TSV, and watch a job get created. Inspect the files underjobs/<job-id>/— especiallydraft_mapping.jsonandcuration_report.json. This makes the "placeholder" concept concrete. - Read
src/harmonizer/job/setup.py— the pre-pass. Plain, deterministic Python. - Read
src/harmonizer/orchestrator/prompts.py— the exact text the agent is told and the tool menu it is given. - Read
src/harmonizer_tools/server.pyand the*_tools.pyfiles — the vetted actions the agent can take. - Then trace
src/harmonizer/orchestrator/loop.pyto see how iterations, early-stop, and the freshness guard tie it together.