Generate Define-XML Metadata With AI in R: Draft, Then Validate

Let an AI copilot draft the variable-level metadata spec that feeds a Define-XML in seconds — then run the metatools checks that catch a hallucinated controlled-terminology value before it reaches a submission

Pharma & Clinical

An AI copilot can draft the ADaM variable-level metadata — the metacore spec tables a Define-XML is generated from — from a plain description in seconds. This lesson does exactly that, then validates the draft against the real data with metatools: check_ct_data catches a confident controlled-terminology hallucination (the AI applies the textbook AGEGR1 grouping the study never used), get_bad_ct names the offending values, and check_variables catches a dropped variable. It draws the honest boundary — metacore and metatools read and check metadata, they never write the define.xml — and keeps accountability where it belongs: the AI drafts the spec fast, the tooling plus a human gate it. On public pharmaverse data, so every check runs.

Published

July 2, 2026

Modified

July 7, 2026

TipKey takeaways
  • An AI copilot drafts the metadata spec fast — not the file. It turns a plain description into the metacore tables (labels, types, lengths, controlled-terminology codelists) that a Define-XML is generated from, in seconds. That scaffold is repetitive, well-patterned work an assistant is good at.
  • The draft is unqualified. A Define-XML is a submission deliverable — a GxP deliverable (the “good practice” quality regulations: GCP, GMP, GLP) — and “the spec looks tidy” was never the standard for a metadata contract a reviewer reads first.
  • The signature catch: the AI applies the textbook age grouping <65 / 65-80 / >80 to AGEGR1, but this study derived 18-64 / >64. check_ct_data() flags it in one line; a glance at the tidy spec does not.
  • metacore/metatools read and check — they never write the define.xml. Generation is a separate tool (Pinnacle 21 or the open-source defineR). Be precise about that boundary.
  • AI changes speed, not accountability. The programmer owns the metadata and the validation that proves it; the tooling and a human catch what the AI got wrong.

Introduction

A statistician hands you an ADaM (Analysis Data Model) dataset and asks for its metadata: labels, types, lengths, and the controlled-terminology codelists that describe every variable. An AI copilot — an AI coding assistant such as Claude, ChatGPT, or GitHub Copilot — will draft that spec faster than you can open a template. The output is the set of tables the metacore package holds, and those tables are what a Define-XML file is generated from — the machine-readable metadata (define.xml) that describes every dataset, variable, codelist, and derivation in a regulatory submission, and the first thing a reviewer opens.

That is genuinely useful, and just as genuinely dangerous. The metadata is a contract, and an AI that confidently fills a codelist with a plausible-but-wrong set of values hands you a spec that looks tidy and is quietly non-conformant. This lesson runs the whole loop on public pharmaverse data: describe the metadata, let the copilot draft the metacore tables, build the metadata object, then use metatools to validate the draft against the real data — and catch the error the AI made.

It is the forward complement to the read-an-existing-Define-XML lesson: that lesson reads a finished define.xml into R with define_to_metacore() and checks data against it; here we draft the metadata that feeds one and validate the draft before it becomes a file. It also puts to work the principle from the capability map that opens this series and the ADTTE-from-an-endpoint demo: AI drafts, you validate.

The boundary: what the AI drafts, and what it does not

Get this exactly right, because it is easy to overstate. Three lines draw the boundary:

  • The AI drafts the metadata spec — the metacore tables (variable labels, types, lengths, and controlled-terminology codelists). It does not author the define.xml file.
  • metacore holds that spec in memory; metatools checks data against it. Neither one writes a define.xml. What actually generates the file is separate tooling — the industry-standard Pinnacle 21 or the open-source R package defineR, both driven from a metadata spreadsheet. The metacore spec is the input to that generation, checked and traceable in R.
  • AI-drafted is not AI-validated. The copilot accelerates the first draft; metatools plus a qualified programmer catch what it got wrong. Accountability for the metadata stays human.

With the boundary set, build a draft and break it.

The metadata to encode

The dataset is ADSL (the Subject-Level Analysis Dataset — one row per subject). We take a small, representative slice of its variables and describe what each should hold:

Variable Label Controlled terminology?
STUDYID Study Identifier
USUBJID Unique Subject Identifier
SUBJID Subject Identifier for the Study
SITEID Study Site Identifier
AGE Age
AGEGR1 Pooled Age Group 1 yes — the study’s age bands
SEX Sex yesM / F
ARM Description of Planned Arm
TRT01P Planned Treatment for Period 01

Two variables carry controlled terminology (CT) — the fixed set of permitted values a variable may take: SEX and AGEGR1. SEX is uncontroversial. AGEGR1 is where a draft goes wrong, so pin down what the data actually contains before trusting any spec:

library(pharmaverseadam)

adsl <- pharmaverseadam::adsl[, c("STUDYID", "USUBJID", "SUBJID", "SITEID",
                                  "AGE", "AGEGR1", "SEX", "ARM", "TRT01P")]

# The controlled terminology this study actually used for AGEGR1
table(adsl$AGEGR1)

  >64 18-64 
  264    42 

This study binned age into 18-64 and >64 — two categories, not the three-band grouping you might expect. Hold that fact; it is the trap.

The AI-drafted metadata spec

Prompted with the description above and told “draft the metacore spec for ADSL,” an assistant produces the tables below. metacore models a define’s metadata as six tables, and it is worth naming each before the code runs:

  • ds_spec — one row per dataset (name, structure, label).
  • ds_vars — which variables belong to the dataset, with key order and whether each is mandatory.
  • var_spec — each variable’s attributes (type, length, label).
  • value_specvalue-level metadata: per variable, its origin and the codelist it links to.
  • codelist — the controlled-terminology codelists: the permitted values, held in a list-column.
  • derivations — the plain-text derivation logic (empty here; these variables are collected or assigned, not derived).

Read the codelist table closely — that is where the AI’s mistake lives. Prompted from the concept “pooled age group,” the assistant reached for the textbook CDISC grouping <65 / 65-80 / >80 instead of the bands this study actually derived. The code builds cleanly and the object assembles without complaint:

suppressPackageStartupMessages(library(metacore))
library(tibble)

vars <- c("STUDYID", "USUBJID", "SUBJID", "SITEID",
          "AGE", "AGEGR1", "SEX", "ARM", "TRT01P")
required <- c("STUDYID", "USUBJID", "SUBJID", "AGE", "SEX", "ARM")

# One row per dataset
ds_spec <- tibble(
  dataset   = "ADSL",
  structure = "one record per subject",
  label     = "Subject-Level Analysis Dataset"
)

# Which variables belong to ADSL (mandatory + core, not the deprecated `keep`)
ds_vars <- tibble(
  dataset   = "ADSL",
  variable  = vars,
  key_seq   = ifelse(vars %in% c("STUDYID", "USUBJID"),
                     match(vars, c("STUDYID", "USUBJID")), NA_integer_),
  order     = seq_along(vars),
  mandatory = vars %in% required,
  core      = ifelse(vars %in% required, "Required", "Permissible"),
  supp_flag = FALSE
)

# Each variable's attributes
labels <- c(STUDYID = "Study Identifier", USUBJID = "Unique Subject Identifier",
            SUBJID = "Subject Identifier for the Study", SITEID = "Study Site Identifier",
            AGE = "Age", AGEGR1 = "Pooled Age Group 1", SEX = "Sex",
            ARM = "Description of Planned Arm", TRT01P = "Planned Treatment for Period 01")
types <- c(STUDYID = "text", USUBJID = "text", SUBJID = "text", SITEID = "text",
           AGE = "integer", AGEGR1 = "text", SEX = "text", ARM = "text", TRT01P = "text")
var_spec <- tibble(
  variable = vars,
  type     = unname(types[vars]),
  length   = NA_integer_,
  label    = unname(labels[vars]),
  format   = NA_character_,
  common   = NA
)

# Value-level metadata: link AGEGR1 and SEX to their codelists
value_spec <- tibble(
  dataset       = "ADSL",
  variable      = vars,
  code_id       = ifelse(vars == "AGEGR1", "CL.AGEGR1",
                  ifelse(vars == "SEX", "CL.SEX", NA_character_)),
  type          = unname(types[vars]),
  origin        = "Assigned",
  derivation_id = NA_character_,
  where         = NA_character_,
  sig_dig       = NA_integer_
)

# The controlled terminology -- AGEGR1 is the AI's HALLUCINATION (textbook, not this study)
codelist <- tibble(
  code_id = c("CL.AGEGR1", "CL.SEX"),
  name    = c("Pooled Age Group 1", "Sex"),
  type    = "permitted_val",
  codes   = list(c("<65", "65-80", ">80"),   # textbook grouping -- WRONG for this study
                 c("M", "F"))
)

# Required even when empty
derivations <- tibble(derivation_id = character(), derivation = character())

# Assemble the immutable metadata object and scope it to ADSL
mc_draft   <- metacore(ds_spec, ds_vars, var_spec, value_spec,
                       derivations = derivations, codelist = codelist,
                       verbose = "silent")
adsl_draft <- select_dataset(mc_draft, "ADSL", verbose = "silent")
mc_draft
── Metacore object contains metadata for 1 datasets ────────────────────────────
→ ADSL (Subject-Level Analysis Dataset)
To use the Metacore object with metatools package, first subset a dataset using
`metacore::select_dataset()`

The object assembles: metacore() returns a valid in-memory metadata model for one dataset, and select_dataset() scopes it to ADSL so metatools can read from it. Nothing here errored — which is exactly the problem. The spec is structurally fine and semantically wrong, and only a check against the real data can tell the difference.

Validate: catch the hallucinated controlled terminology

check_ct_data() sweeps every controlled-terminology column in the dataset and compares its values to the codelist the spec declares. Run it against the AI’s draft:

library(metatools)

invisible(check_ct_data(adsl, adsl_draft))
Warning: ✖ Invalid controlled terminology detected
ℹ Variable: AGEGR1 | Codelist: CL.AGEGR1
ℹ Values not permitted '18-64' and '>64'

The check fails loudly: AGEGR1 carries the values 18-64 and >64, and the AI’s CL.AGEGR1 codelist (<65 / 65-80 / >80) permits neither. SEX passes silently — the AI got that one right. To pull the offending values back as data — to log them, count them, or drive a fix — use get_bad_ct():

library(metatools)

get_bad_ct(adsl, adsl_draft, "AGEGR1")
[1] "18-64" ">64"  

"18-64" ">64" — the two real categories the draft’s codelist does not allow. This is the whole lesson in one line: the AI produced a confident, tidy, textbook-plausible codelist, and it does not match the study. A glance at the spec would not catch it; a one-line check does.

Fix the codelist and re-check

The fix is to align the codelist to the study’s actual derivation. Patch the one bad entry, rebuild the metadata object, and re-run the check (this reuses the spec tables built above):

library(metacore)
library(metatools)

# Align AGEGR1 to the study's real age bands
codelist$codes[codelist$code_id == "CL.AGEGR1"] <- list(c("18-64", ">64"))

mc_fixed   <- metacore(ds_spec, ds_vars, var_spec, value_spec,
                       derivations = derivations, codelist = codelist,
                       verbose = "silent")
adsl_fixed <- select_dataset(mc_fixed, "ADSL", verbose = "silent")

invisible(check_ct_data(adsl, adsl_fixed))
✔ All controlled terminology checks passed
library(metatools)
get_bad_ct(adsl, adsl_fixed, "AGEGR1")
character(0)

✔ All controlled terminology checks passed, and get_bad_ct() now returns character(0) — no offending values. The metadata’s controlled terminology conforms to the data it describes.

The other axis: does every variable belong?

Controlled terminology is one half of conformance; the other is structural — does the spec list the right variables? check_variables() compares the dataset’s columns to the spec and reports anything missing or extra. A second common AI slip is to silently drop a variable from the spec. Rebuild the metadata without TRT01P to see the structural check fire:

library(metacore)
library(metatools)

# A spec that dropped TRT01P (the AI omitted it)
mc_missing <- metacore(
  ds_spec,
  ds_vars[ds_vars$variable != "TRT01P", ],
  var_spec[var_spec$variable != "TRT01P", ],
  value_spec[value_spec$variable != "TRT01P", ],
  derivations = derivations, codelist = codelist, verbose = "silent"
)
adsl_missing <- select_dataset(mc_missing, "ADSL", verbose = "silent")

invisible(check_variables(adsl, adsl_missing, strict = FALSE))
Warning: In: [check_variables(adsl, adsl_missing, strict = FALSE)]

The following variables do not belong: TRT01P
# The complete, corrected spec has every ADSL variable
invisible(check_variables(adsl, adsl_fixed, strict = TRUE))
No missing or extra variables

The first check warns — The following variables do not belong: TRT01P — because the data has a column the spec never declared. The complete spec reports No missing or extra variables. The same discipline extends to any dataset: point the checks at ADAE (the adverse-events analysis dataset) or any other, and they surface exactly where the metadata and the data disagree. (Passing strict = TRUE turns the warning into a hard error — useful when you want a pipeline to stop on a mismatch.)

The honest gate

Notice what the checks do and do not do. By default they warn — they surface a problem and let execution continue; they do not silently block a bad spec from moving downstream. So the real gate is a line you write and a judgement you make:

library(metatools)

# The programmatic gate: zero off-codelist values before the metadata advances
length(get_bad_ct(adsl, adsl_fixed, "AGEGR1")) == 0
[1] TRUE

TRUE. The check surfaced the AI’s error; a human read it, fixed the codelist, and confirmed the gate is green before the metadata feeds anything. That division is the point of the whole series. The AI drafted the spec fast — real time saved on repetitive, well-patterned tables. But it produced a codelist that was plausible-and-wrong and a spec that dropped a variable, and metatools plus a person caught both. The programmer owns the metadata and the validation that proves it; the AI changes the speed of the first draft, never the accountability — and, to be exact about the boundary one last time, the metacore spec is checked and traceable in R, but the define.xml itself is generated by Pinnacle 21 or defineR, not by this code.

🟢 With an AI agent

Ask Prova “An AI copilot drafted my ADaM metadata as metacore tables — how do I validate the draft with metatools so a hallucinated codelist or a dropped variable can’t reach a Define-XML?” — it answers grounded in this pillar’s lessons, with runnable pharmaverse code (check_ct_data(), get_bad_ct(), check_variables()) you can try on example data. The runtime is the judge. Ask Prova →

Common issues

select_dataset() errors with `derivation_id` not found (or a “core” column error). Each metacore table must carry its full expected column set, even when some columns are all NA. If value_spec omits derivation_id, or ds_vars omits core, metacore() prints an “incorrect column names” warning and the later select_dataset()/metatools call fails. Build each table with its complete columns (ds_vars: dataset, variable, key_seq, order, mandatory, core, supp_flag; value_spec: dataset, variable, code_id, type, origin, derivation_id, where, sig_dig) and NA-fill what you do not have.

Using keep instead of mandatory in ds_vars. keep is the deprecated name; current metacore expects mandatory (a logical). Use mandatory, and give core one of the accepted words — Required, Expected, or Permissible — or the build warns.

Assigning codes to codelist$codes the wrong shape. codes is a list-column. For a type = "permitted_val" codelist, each element is a plain character vector (c("18-64", ">64")); for a type = "code_decode" codelist, each element is a two-column tibble of code/decode pairs. Assign with codelist$codes[i] <- list(...), not codelist$codes[i] <- c(...).

Treating a passing check as the gate. check_ct_data() and check_variables() warn by default — they do not stop a bad spec from advancing. The real gate is an explicit assertion you own, e.g. length(get_bad_ct(data, mc, "AGEGR1")) == 0, or check_variables(..., strict = TRUE) to make a mismatch a hard error. The check surfaces the problem; you decide whether the metadata passes.

Frequently asked questions

No — it drafts the metadata, not the file. An AI copilot can draft the metacore spec tables (labels, types, codelists) that a Define-XML is generated from, far faster than you can type them. But generating the define.xml file is a separate step done by Pinnacle 21 or the open-source defineR package, driven from a metadata spreadsheet. metacore and metatools read and check metadata; they never write the define.xml.

Yes — that is exactly what it is for. check_ct_data() compares each controlled-terminology column’s values against the codelist your spec declares and warns on any value that is not permitted; get_bad_ct(data, metacore, "AGEGR1") returns the offending values as a character vector (or character(0) when the column is clean). In this lesson it caught an AI codelist of <65 / 65-80 / >80 against data that actually used 18-64 / >64.

No. metacore holds metadata in memory — it reads a define.xml (or a spec spreadsheet) into structured tables, and you can build those tables directly, as here. It does not generate a define.xml, and neither does metatools or xportr. Generating the file is Pinnacle 21 or defineR. Be precise about the boundary: metacore is the metadata; the define.xml is generated from it by other tooling.

Only after it is validated and owned by a qualified programmer — the same standard as any code or spec. An AI draft is unqualified: a Define-XML is a submission deliverable, so the metadata must clear the same conformance checks a hand-authored spec would (check_ct_data(), check_variables(), review against the data and the analysis) before it feeds a define.xml. The AI changes how fast you get a first draft, not who is accountable for it.

Drafting the metadata means authoring the spec — the metacore tables that describe each dataset, variable, codelist, and derivation. Generating the define.xml means turning a finished spec into the define.xml file a reviewer opens, which Pinnacle 21 or defineR does from a metadata spreadsheet. An AI copilot helps with the first (draft the spec, then validate it); the second is a deterministic tooling step. Keeping them separate is what keeps the boundary — and the claims — honest.

Test your understanding

An AI copilot drafts the SEX codelist for ADSL as c("Male", "Female"), but the data codes sex as M and F.

  1. In prose: name the check that catches this and say what get_bad_ct(adsl, adsl_mc, "SEX") returns before and after you fix the codelist.
  2. In R: starting from the lesson’s objects, set the SEX codelist to the wrong c("Male", "Female"), rebuild the metadata, and confirm check_ct_data() flags SEX and get_bad_ct() returns the two bad values.

The controlled-terminology sweep is check_ct_data(data, metacore); the values-as-data accessor is get_bad_ct(data, metacore, "SEX"). A codelist of Male/Female permits neither M nor F, so every SEX value is off-codelist — get_bad_ct() returns "M" "F" until you align the codelist to c("M", "F"), after which it returns character(0).

The check is check_ct_data(), and get_bad_ct(adsl, adsl_mc, "SEX") returns "M" "F" for the wrong codelist (both real values are absent from Male/Female), then character(0) once the codelist is c("M", "F").

library(metacore)
library(metatools)

# Break the SEX codelist the way the AI did
codelist$codes[codelist$code_id == "CL.SEX"] <- list(c("Male", "Female"))

mc_bad   <- metacore(ds_spec, ds_vars, var_spec, value_spec,
                     derivations = derivations, codelist = codelist, verbose = "silent")
adsl_bad <- select_dataset(mc_bad, "ADSL", verbose = "silent")

check_ct_data(adsl, adsl_bad)             # warns: SEX values not permitted
get_bad_ct(adsl, adsl_bad, "SEX")         # "M" "F"

# Fix it
codelist$codes[codelist$code_id == "CL.SEX"] <- list(c("M", "F"))
mc_ok   <- metacore(ds_spec, ds_vars, var_spec, value_spec,
                    derivations = derivations, codelist = codelist, verbose = "silent")
adsl_ok <- select_dataset(mc_ok, "ADSL", verbose = "silent")
get_bad_ct(adsl, adsl_ok, "SEX")          # character(0)

The pattern is identical to the AGEGR1 catch: the AI’s codelist was plausible and wrong, and a one-line check told the difference.

A. Valid metadata — it built cleanly and the tables look right. B. An unqualified draft — until it is checked against the real data (check_ct_data(), check_variables()) and reviewed, a clean build says nothing about whether the codelists match the study. C. Ready to generate a define.xml, since metacore holds a complete spec.

B. A clean metacore() build means the tables are structurally consistent — nothing about whether the AGEGR1 codelist matches the data (the draft in this lesson built fine and used the wrong age bands). A mistakes “assembles” for “conforms.” C overstates the boundary: metacore holds the spec, but a define.xml is generated by Pinnacle 21 or defineR, and only after the metadata is validated.

Conclusion

An AI copilot is a real accelerator for submission metadata: it turns a plain description of an ADaM dataset into the metacore spec tables a Define-XML is generated from, in seconds, and that scaffold is the repetitive part you were going to type anyway. But the draft arrives unqualified, and this lesson showed why the word matters — a confident, tidy AGEGR1 codelist of <65 / 65-80 / >80 that did not match a study using 18-64 / >64, caught not by reading the spec but by a one-line check_ct_data(), plus a dropped variable check_variables() surfaced. metacore and metatools read and check the metadata; they never write the define.xml. The AI drafts the spec fast; you own the codelists, the conformance, and the gate that says the metadata is right. AI changes the speed of the first draft. It changes nothing about who answers for the result. The runtime is the judge.

Note

This lesson is reproducible: the AI-drafted metacore spec, the controlled-terminology check that catches its hallucinated codelist, the corrected codelist, and the variable-conformance check all reproduce here on public pharmaverse data. Copy any block and run it to reproduce them. The AI drafting step happens in your own tooling; the R that validates the draft is real and executes. The runtime is the judge.

References

  • CDISC Define-XML — the standard: the machine-readable submission metadata the metacore spec feeds.
  • metacore — CRAN — models a define’s metadata as tidy tables (ds_spec, ds_vars, var_spec, value_spec, codelist, derivations); the metacore() constructor and select_dataset() used here.
  • metatools — CRAN and its documentation — checks data against a metacore spec: check_ct_data(), get_bad_ct(), check_variables().
  • Pinnacle 21 Define-XML 2.1 support and defineR — CRAN — the tools that actually generate a define.xml from a metadata spec; metacore/metatools do not.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Generate {Define-XML} {Metadata} {With} {AI} in {R:} {Draft,}
    {Then} {Validate}},
  date = {2026-07-02},
  url = {https://www.datanovia.com/learn/pharma-clinical/08-agentic-clinical-programming/ai-generates-define-xml-metadata},
  langid = {en}
}
For attribution, please cite this work as:
“Generate Define-XML Metadata With AI in R: Draft, Then Validate.” 2026. July 2. https://www.datanovia.com/learn/pharma-clinical/08-agentic-clinical-programming/ai-generates-define-xml-metadata.