Why we let an AI write your OPC UA model
Ask a large language model how many R's there are in "strawberry" and there is a decent chance it says two.
So here is a fair question: why would you let one write an OPC UA information model — a thing with a normative type hierarchy, mandatory members, modelling rules, and a companion specification that says exactly where a process value is allowed to live?
The answer is that it doesn't get the last word.
The problem with confident text
An LLM produces text that looks like the training data. For an information model, "looks right" is a low bar and a dangerous one. The failure mode is not gibberish — it is a model that reads beautifully and is subtly wrong:
- A type that subtypes
BaseObjectTypewhen the companion spec says it must derive fromdi:ComponentType. - A temperature declared straight on the type, when DI requires process values
to sit inside
ParameterSet. - An engineering unit written as
"degree Celsius", which is not a UNECE Rec. 20 symbol.°Cis. - A browse name that reads perfectly and belongs to no namespace anyone declared.
Every one of those compiles in a human's head and fails in an OPC UA stack. None of them is fixed by a better prompt, because the model has no way to know it is wrong. It has no compiler.
Ours does
The generation pipeline is not "ask the model, print the answer". It is a loop with a judge in it:
- Detect the companion specifications the description implies. A robot arm pulls in Robotics; a machine tool pulls in MachineTool. This matters more than it sounds: most modelling errors are not syntax, they are reinventing a type that the OPC Foundation already standardised.
- Generate the YAML.
- Compile it. The same engine that runs in the CLI and the editor, with the same diagnostics.
- Feed the diagnostics back and let the model correct itself.
- Repeat until the model is clean or the attempts run out.
Step 3 is the whole argument. The LLM proposes; the compiler disposes. What reaches you has been through a validator that does not care how plausible the YAML looked.
And when it cannot converge, you are told so — with the remaining diagnostics — rather than handed a confident wrong answer.
A strawberry house
Here is a real one. The prompt:
A climate-controlled compartment for growing strawberries. Air temperature and humidity, substrate moisture, and a method to run the irrigation for a set duration.
What comes back:
# yaml-language-server: $schema=../../schemas/nodeset2.schema.json
namespaceUri: http://example.com/UA/StrawberryHouse/
version: 1.0.0
publicationDate: "2026-08-28T00:00:00Z"
namespaces:
- di
objectTypes:
- browseName: StrawberryHouseType
subtypeOf: di:ComponentType
description: |
A climate-controlled growing compartment for strawberries: air climate,
substrate moisture, and the irrigation valve that responds to them.
components:
- browseName: di:ParameterSet
components:
- browseName: AirTemperature
typeDefinition: ua:AnalogUnitType
dataType: ua:Double
engineeringUnits: "°C"
description: Air temperature at canopy height.
- browseName: RelativeHumidity
typeDefinition: ua:AnalogUnitType
dataType: ua:Double
engineeringUnits: "%"
description: Relative humidity of the compartment air.
- browseName: SubstrateMoisture
typeDefinition: ua:AnalogUnitType
dataType: ua:Double
engineeringUnits: "%"
description: Volumetric water content of the growing substrate.
- browseName: di:MethodSet
methods:
- browseName: StartIrrigation
description: Open the irrigation valve for a fixed duration.
inputArguments:
- name: Duration
dataType: ua:Duration
description: How long to irrigate, in milliseconds.
outputArguments:
- name: Accepted
dataType: ua:Boolean
description: False when a cycle is already running.
instances:
- browseName: House1
typeDefinition: StrawberryHouseType
organizedBy: /ua:Objects
initializers:
- variable: di:Manufacturer
value: "Acme Horticulture"
- variable: di:Model
value: "GH-1200"
Read it as a reviewer, and notice what is not wrong:
StrawberryHouseTypederives fromdi:ComponentType, not fromBaseObjectType. It is a device; DI is where devices live.- The three measured values sit inside
di:ParameterSet, redeclared to make it mandatory. That is what the Devices specification requires, and the compiler emitsDSL-W-DI-001when it is not done. - The units are
°Cand%— real UNECE symbols, not prose. StartIrrigationis indi:MethodSet, with aua:Durationin and aua:Booleanout, so a caller can be told the valve is already running.House1carriesdi:Manufactureranddi:Model, becausedi:ComponentTypeinherits them as mandatory from the DI nameplate interface. An instance without them would be non-conformant.
None of that is exotic. It is exactly the set of decisions that takes a newcomer a week of reading specifications to make correctly, and takes an expert five minutes of tedium.
This model is compiled in our CI on every commit, along with every other example on this site. If it stopped compiling, the build would fail before you ever read it.
What this does not do
It does not know your plant. It produced a conformant model of a strawberry
house; whether the strawberry house in front of you has one irrigation zone or
twelve, and whether SubstrateMoisture is the measurement your controller
actually publishes, is yours to decide.
Treat it the way you would treat a competent colleague's first draft: the structure is sound and the conventions are right, and the domain judgement is still your job. The fastest review is the diagram — the structure is obvious there in a way it is not in YAML.
Try it
Describe something to the online editor, or hand the same job to an agent through the MCP server, which exposes the identical pipeline as a tool.
Either way, what you get back has already been through the compiler. That is the only reason it is worth trusting — and it is why "how many R's in strawberry" turns out to be the wrong question to ask about it.
