The tag model
The tag store is the seam everything meets at: the IEC program, the field driver, the HMI/API, and the editor’s live values all read and write the same named values. One scan looks like this:
driver.ReadInputs() ──(Input tags)──▶ ┌───────────┐ ──(Output tags)──▶ driver.WriteOutputs() │ tag store │HMI / POST /api/tags ──(writes)─────▶ │ │ ──(reads)────────▶ HMI / editor pills └───────────┘ ▲ │ program writes │ program reads (coils) ▼Every program sees the project’s tags without declaring them. A tag in
nautilus.yaml (or a tag-files: entry) is in scope in every PROGRAM as
it is — the tag table is the declaration, as a Codesys GVL, a TIA PLC tag
table or Logix controller scope is. A VAR_EXTERNAL naming one stays legal,
as IEC’s explicit form. Either way the name binds the tag; it does not
create it. Using TempC in a program means “resolve this name in the tag
store at scan time”, and nothing more. Existence comes from a write, and
there are exactly four writers:
- a seed in the Go composition (initial value, exists from scan one),
- a driver delivering it as an input each scan,
- an operator writing a tag the project declares through the HMI or
POST /api/tags(a name nothing declares, a CONSTANT’s included, is refused with a 400 naming it, never created), - the program itself assigning it (a coil write creates the tag).
The one rule that bites: writes create, reads fault. Reading a tag
nobody has ever written stops the scan with undefined tag <name> —
deliberately, so a mis-wired binding fails loudly instead of running on a
silent zero. The classic trap is a seal-in latch that reads its own coil
on scan one, before the first write; the fix is a seed. (In the FBD
diagram, with live values on, externals with no backing tag are flagged —
an amber no tag badge in the vars panel and on any chip that reads one —
so you see this before the download, not after.)
A faulting scan is counted on the diagnostics page (logicErrors,
lastError) and written to naut’s log on transitions, per task: one
warning when a task starts faulting, with the error, and one info line
when it recovers, with how many scans faulted and for how long. While it
keeps faulting, a new error message is logged at most once a minute, so a
flapping fault can’t flood the log. A driver read or write that keeps
failing (ioErrors, lastIOError) is logged the same way.
Inputs from a device that is down
Section titled “Inputs from a device that is down”An input delivered by a communications driver is the exception: it is
seeded, so a device that is down when the controller starts does not stop
the programs that read its tags. This is how a Logix controller behaves.
It keeps scanning with a consumed tag or a remote rack’s inputs at their
last (or initial) value, and the logic checks the connection’s fault bit.
| Driver | An input it has not delivered yet… | Status tag to check |
|---|---|---|
modbus | reads its init:, or the zero of its type | <source>__Online |
eip | reads its init:, or the zero of its type | <driver name>__Online (eip__Online) |
sparkplug-host | reads its init:, or the zero of its type | <site>__Online, <site>_<device>__Online |
snmp, redfish, prometheus | reads its init:, or the zero of its type | <source>__Online |
plcnext, optommp (local I/O) | faults the scan, as before | <id>__Online |
replay, memory | faults the scan, as before | — |
The decision is per tag, by the driver that delivers it, so a drivers:
list that mixes a Modbus skid with a controller’s own I/O seeds only the
Modbus tags. Local I/O keeps the loud fault: there, an input that never
arrives means the configuration is wrong, not that a cable is out.
The type of the zero is the tag’s type:, else the type the programs
declare the tag as, else the type the driver’s binding delivers (a Modbus
binding’s format; an EtherNet/IP or Sparkplug binding’s datatype). A tag
none of those can type stays absent until its first delivery, and
naut check warns about it. No program reads such a tag, since a program
that read it would have declared its type.
A seed is not a reading. Until the device answers, the driver reports the
tag’s quality as notConnected, which the HMI, the Sparkplug node and the
OPC UA server all carry. Logic should check __Online:
Run := SkidRun AND Skid__Online;Once a real value arrives it replaces the seed. After a disconnect a tag
holds its last value (quality stale), as it always has.
Tags in scope: the rules
Section titled “Tags in scope: the rules”- A manifest tag needs no declaration in a PROGRAM — ST, ladder, FBD or
SFC. Its type is its
type:; with notype:, the type itsinit:implies (TRUE/FALSEBOOL, a number REAL,T#5sTIME, other text STRING). A tag with neither has no knowable type, and naming it says so:"Valve" is a project tag with no type — give Valve a type: (or an init:) in the manifest, or declare it in VAR_EXTERNAL. VAR_EXTERNALstays legal and wins for that program. When the tag states atype:, the declaration must agree with it —VAR_EXTERNAL Speed : BOOLagainsttype: REALis an error at the declaration. When the tag has only aninit:, the declaration sets its type (VAR_EXTERNAL Count : DINTwithinit: 0seeds a DINT), and theinit:must be a value of that type:init: TRUEagainstVAR_EXTERNAL Speed : REALis an error (tag Speed (declared REAL by a program): init: want REAL, got a boolean), as isinit: 2.5against a DINT. Programs that bind one untyped tag with different types (one declaresCount : DINT, another readsCountimplicitly as the REAL itsinit: 0implies) are an error naming both tasks; atype:on the tag settles it.- A local shadows a tag. A program’s own
VAR Level : REAL;is that program’s variable, and the tagLevelgoes unread and unwritten there. That is IEC scoping, so it compiles;naut checkand the editor warn (local Level shadows the project tag Level). - Function blocks and functions do not see tags implicitly. A block
that reaches a tag says so in its own
VAR_EXTERNAL, so it stays self-contained and can move to another project. Pass values in throughVAR_INPUT/VAR_IN_OUTwhere you can. - A GVL file works too. A library
.stholding a file-levelVAR_GLOBALblock (Codesys’sgvl.st) declares globals for every program. A global the manifest does not also declare becomes astatetag starting at the zero of its type; one it does declare is that tag (the manifest supplies its role, init, unit and desc).VAR_GLOBAL CONSTANTentries are constants, not tags.
A program binds only the tags it names: an unused tag costs a program nothing per scan.
Declaring tags: one entry per tag
Section titled “Declaring tags: one entry per tag”runtime.Options.Tags states each tag’s role once — which direction it
moves each scan, its initial value, and its HMI documentation — instead of
spreading the name across the flat Inputs/Outputs/Seed/Meta fields
(all of which still work and compose with Tags):
Tags: []runtime.TagDef{ // Field input: the driver produces it. NOT seeded — if the driver // doesn't deliver it, you want the loud fault, not a silent zero. // (The name must also appear in the driver's ReadInputs map.) runtime.Input("TempC", runtime.Desc("Tank temperature"), runtime.Unit("°C")),
// An input from a device that can be down (Modbus, EtherNet/IP, …): // runtime.Comms() seeds it with its Init or the zero of its type, so // the program runs until the device answers. The manifest loader sets // this for you from the driver. runtime.Input("FlowLps", runtime.Comms(), runtime.Unit("L/s")),
// Operator setpoint / config: seeded so it exists from scan one; // the HMI or POST /api/tags writes it later. runtime.Setpoint("TempSP", 65.0, runtime.Unit("°C")),
// Field output: the logic produces it, the driver consumes it. // Init() any output the logic also READS — the seal-in latch. runtime.Output("PumpRun", runtime.Init(false), runtime.Desc("Pump run command")),
// Logic-owned state that must survive read-before-write: latches, // integrators, mode words. runtime.State("Mode", int64(0), runtime.Desc("0=off 1=auto 2=manual")),},Picking a role, by use case:
| You want a tag that… | Use | Backed by |
|---|---|---|
| comes from a sensor / the field | Input(name, …) | driver ReadInputs map + this entry |
| goes to an actuator / the field | Output(name, …) | your logic writes it (coil) |
| …and your logic also reads it (latch) | Output(name, Init(v), …) | the seed covers scan one, then the coil |
| an operator adjusts (setpoint, gain, limit) | Setpoint(name, initial, …) | the seed, then HMI / POST /api/tags |
| your logic owns across scans (integrator) | State(name, initial, …) | the seed, then the coil |
| the HMI watches but the field never sees | plain coil write — no entry | the program creates it on first write |
A tag’s type:
Section titled “A tag’s type:”type: is the tag’s data type, the Data Type column of a vendor tag
table. It takes every IEC elementary type — BOOL, SINT … ULINT,
BYTE … LWORD, REAL, LREAL, TIME, STRING — a TYPE the project’s
ST declares (a UDT or an enumeration), or an
ARRAY[lo..hi] OF either:
tags: - { name: FT101_Raw, role: input, type: INT, init: 0 } - { name: PumpRun, role: output, type: BOOL } - { name: SettleT, role: setpoint, type: TIME, init: T#2s } - { name: Profile, role: setpoint, type: 'ARRAY[1..4] OF REAL', init: [10.0, 20.0] } - { name: P101, role: state, type: Motor }type: and init: must agree: type: INT with init: 2.5 is a load
error naming the tag and its type (tag N (type INT): init: want INT, got a number). A typed setpoint or state tag with no init: starts at the
zero of its type. A type that is none of the above names what the project
does declare (type Widget is neither an IEC elementary type … nor a TYPE this project's ST declares (known: DoseRecipe, Motor)); a function-block
type is refused, since an instance is program state, not a tag. So is a
type that holds one: an array of a block, standard or the project’s own,
at any nesting (tag Timers: ARRAY[1..2] OF TON — TON is a function block; an instance is program state, not a tag), and a TYPE whose STRUCT has a
block member (tag Line1: Station — member Delay is a TON, a function block; …). A tag with no type: is held to the same rule through the
type a program declares for it: VAR_EXTERNAL Timers : ARRAY[1..2] OF TON; naming a manifest tag is refused at load (tag Timers: program Main declares it ARRAY[1..2] OF TON — TON is a function block; …). An array of
timers belongs in the program’s VAR; publish the values it produces as
tags.
GET /api/meta reports each tag’s declared type beside its desc and
unit, and an enumeration’s members, so a client can tell an enumerated
value (which the stream carries as its member’s name, "Run") from a
STRING. Program locals get the same entry under locals; a struct or
function block lists members only where an enumeration sits beneath:
"tags": { "Mode": { "desc": "Pump mode", "type": "PumpMode", "enum": [{ "name": "Off", "value": 0 }, { "name": "Run", "value": 10 }] }, "Label": { "type": "STRING" }, "P101": { "type": "Pump", "members": { "State": { "type": "PumpMode", "enum": [ … ] } } }}The VS Code extension reads it to show Run bare (and the type, PumpMode · enum, on hover) where a STRING would read "Run", and to offer the
members when you set or force the tag.
A scalar seed takes the program’s type
Section titled “A scalar seed takes the program’s type”An init: on a plain (untyped) tag is seeded as whatever the programs
declare that tag to be. init: 0 on a tag the logic binds as
Counter : DINT seeds an integer, and the node births it to a Sparkplug
host as an Int64; init: 65 on TempSP : REAL is still a REAL (a
Double on the wire), so an integer literal is fine wherever a REAL is
meant. A value the declared type cannot hold — init: 2.5 on an INT —
is a load error naming the tag and the type. A tag no program declares has
nothing to resolve against and seeds a number as a REAL, as it always has.
Seeding struct members
Section titled “Seeding struct members”A manifest tag naming a UDT (type: Motor1Speed) seeds to the zero of that
type by default — every member FALSE/0/0.0 until something writes it.
For a setpoint or state tag, init: can instead be a mapping of member
name to value, nested for a member that is itself a struct:
tags: - name: ZONE15_FIT_001 role: state type: AnalogInput init: RAWMIN: 6553.0 RAWMAX: 32767.0 HHSP: 2800.0 ALMDLY: 5 - name: ZONE15_PMP_015 role: state type: Motor1Speed init: STRTTMRSP: 30 LVL: { HSP: 60.0, LSP: 40.0 } # nested struct memberA TIME member (a recipe’s step times) seeds from the ST literal
(T#2s, TIME#1m30s), the same duration without its prefix (90s,
500ms), or a whole number of milliseconds. An ARRAY member seeds from a
list, element by element from its lower bound; elements the list leaves
out stay zero:
- name: RecipeA role: setpoint type: DoseRecipe # STRUCT TargetL : REAL; SettleTime : TIME; Steps : ARRAY[0..2] OF TIME; … init: { TargetL: 10.0, SettleTime: T#2s, Steps: [T#1s, 2s] }Every member the mapping omits still takes the zero of its own field type —
init: names only what needs a real starting value, not the whole shape.
This is what replaces a first-scan IF NOT CfgDone THEN ... END_IF block of
plain field assignments: the same setpoints, declared once where the tag
already is, instead of duplicated as ST in every program that uses the type.
An unknown member name is a load error naming the tag and the member path
(tag ZONE15_FIT_001: init: unknown member RAWMN (did you mean RAWMIN?)), and
a struct-typed tag given a scalar init: — rather than a mapping — is an
error too. A generator (naut eip tags, naut tags import-csv)
round-trips the same nested shape through a tag-files: entry.
Writing a member: POST /api/tags with Tag.Member
Section titled “Writing a member: POST /api/tags with Tag.Member”A UDT tag is one value in the store, so an operator writing one of its
members is a read-modify-write of the whole struct. The API does that for
you — name takes a dotted member path, to any depth:
curl -X POST localhost:8080/api/tags \ -d '{"name": "ZONE15_PMP_015.START", "value": true}'curl -X POST localhost:8080/api/tags \ -d '{"name": "ZONE15_PMP_015.LVL.HSP", "value": 60}'value may also be an object, to set several members of one tag at once:
{ "name": "ZONE15_PMP_015", "value": { "START": true, "LVL": { "HSP": 60 } } }That is a merge: members the object doesn’t name keep their current
values. Deliberately unlike init: above, which zero-fills the members its
mapping omits — seeding builds a value from nothing, a write edits one the
plant is already running on.
- Nothing is created. An unknown tag, a misspelled member, or a member
path into a scalar tag is a
400carrying the reason (tag ZONE15_PMP_015: unknown member STRAT (did you mean START?)), never a new top-level tag literally namedZONE15_PMP_015.STRAT— which no program reads and a Sparkplug edge would publish as a bogus metric. - The leaf keeps its type. A number lands on a
REALmember as a REAL and on aDINTmember as an integer; a mismatch istag ZONE15_PMP_015.START: want BOOL, got a number, not a silent retype. - An enumerated member takes its name —
{"name": "Recipe.Mode", "value": "Run"}, any case,Mode#Runqualified, or the member’s integer — and stores the named value, exactly as a write of a whole enumerated tag does. A name that is no member is a400that lists them (tag Recipe.Mode: "Stop" is not a member of Mode (Idle, Run, Fault)). - An array element is
[n], counted from the array’s declared lower bound:Recipe.Steps[2].Mode,Line[0].Speed. An index outside the bounds is a400that gives them. A listvaluemerges onto an array element by element ({"Steps": [{"Mode": "Run"}]}sets the first step’s mode). - The role rule is the root tag’s, because the store holds whole tags. A
member of a driver-owned
inputis refused — the driver replaces the whole value before the next scan, so the edit could not survive one cycle.setpoint,stateandoutputroots are writable; an output is exactly how a Sparkplug host commands an edge node, and a struct-typed output needs aninit:so it exists before the first write. GET /api/metareports"memberWrites": true, so an HMI can tell a controller that resolves member paths from an older one that swallowed them.
The same dotted (and indexed) paths work everywhere else a tag is
addressed: a test manifest’s given:/expect:, a force, the built-in
dashboard’s tag table (expand a
writable struct tag and each leaf is editable), and the HMI kit’s
rt.writeTag('ZONE15_PMP_015.START', true), which resolves to null on
success or the controller’s reason when it refuses.
Observing scans: Runtime.OnScan
Section titled “Observing scans: Runtime.OnScan”A Go-level subsystem that needs to react to the tag store every cycle — an
alarm engine evaluating AI.HH after each scan, for instance — registers
with rt.OnScan(func(t *runtime.Tags) { ... }) instead of polling on its
own ticker or wrapping the driver. It fires once per main-task Scan(),
after the program has run and any driver outputs have been written, so the
callback sees the store exactly as that scan left it; it also works
unchanged under a test’s virtual clock, since it is driven by Scan()
itself rather than by wall-clock time. Registered observers run
synchronously, in registration order, and a panic in one is recovered and
logged rather than taking the scan down — but an observer must not block or
write tags, since it runs inside the same lock that serializes every scan.
OnScan returns a cancel func to unregister; reading a member path from
inside a callback with Tags.ReadPath("AI.HH") — the Go-level counterpart
of the dotted paths above, returning a plain scalar for a leaf or an
ir.Value for a struct sub-tree — is safe and does not deadlock. See the
doc comments on runtime.Runtime.OnScan and runtime.Tags.ReadPath for
the full contract.
Serving the HMI from the controller
Section titled “Serving the HMI from the controller”server.hmi points the controller at a built HMI — a SvelteKit
adapter-static build (npm run build → build/), or any other static SPA
bundle — and it serves that directory at / instead of the built-in
dashboard:
server: hmi: ./hmi/buildThe path is relative to the project and must resolve inside it, the same
rule as tag-files: and driver.manifest: what naut build ships is
what a reviewer can see in the checkout. An unmatched, non-/api path
falls back to the bundle’s index.html, so client-side routing (SvelteKit’s
router, or any other SPA router) resolves a deep link itself instead of
getting a 404 from the controller, which has no idea such a route exists.
The built-in dashboard doesn’t disappear — it moves to /_nautilus/ (its
assets to /_nautilus/assets/), so the raw tag table is still one URL away
when you need it. /api/* and /api/stream are untouched either way.
The HMI must call the API same-origin — a relative /api/... base URL,
not an absolute host. server.hmi and the tag API are one process on one
address, so there is nothing to configure; an app built against a separate
controller URL (the PUBLIC_NAUTILUS_URL-style env var some HMI deploys
use for a standalone dev server) should leave that unset, or point it at
"", when it’s built to be served this way.
naut build embeds the HMI’s build output in the archive exactly like
every other project file (and prints a warning if the embedded project
comes out over ~50 MB — a sign a dependency tree rode along by accident,
not the HMI build itself); naut run serves it straight off disk. Set
it once, and go build-style “one file to ship” applies to the operator
screen too.
build/ is normally gitignored — it’s the HMI’s own build output, not a
source file — so a fresh clone that hasn’t run npm run build in the HMI
project yet has no server.hmi directory. Neither naut run nor naut build fails on that: both warn (server.hmi: ... is not built yet) and
fall back to the built-in dashboard at / instead of the HMI — run
straight off disk, build in the binary it ships. Run the HMI’s own
build and run/build again to serve or embed it.
Adding a field input end to end
Section titled “Adding a field input end to end”Adding a new field input to a manifest project is one entry —
- { name: testExt, role: input, type: REAL, init: 0.0 } — and the driver
binding that delivers it; every program can then read testExt as it is.
In the Go tier it is two more places by the same name: produce it in the
driver ("testExt": p.testExt in the ReadInputs map) and bind it in the
composition (runtime.Input("testExt"); give it a type with
runtime.Typed or a seed with runtime.Init, or declare it in the
program’s VAR_EXTERNAL, so the programs know its type). The Inputs
list is a deliberate allowlist — a driver can’t spray arbitrary names into
the store — which is why the driver map alone isn’t enough.
Performance notes: write generations
Section titled “Performance notes: write generations”The tag store is the seam everything meets at, which also makes it the thing everything sweeps. A controller whose store is 500 UDT tags of 40 members has 20,000 leaves in it, and the naive version of each consumer walks all of them on its own clock: the driver copy writes every input every scan, the Sparkplug edge deep-compares every metric against its last published value every publish interval, the SSE frame renders the whole store four times a second, and the runtime hands the driver every output every scan. None of that is logic. On a 1s-scan controller it dwarfed the logic by two orders of magnitude.
The store answers all of it with one number. Every stored value carries the store generation at which it last CHANGED, and a write that stores a value equal to the one already there is not a change: the counter does not move, and the tag keeps the value it had. The tag store holds values, not deliveries — re-delivering the same reading is not an event.
That makes “has this changed since I last looked?” an integer comparison rather than a walk:
| Consumer | Question | What it compares |
|---|---|---|
Scan() output push | did any output move? | Tags output generation vs the last successful WriteOutputs |
| Sparkplug RBE | did this metric move? | the metric’s Sample.Gen vs the generation it was last published at |
| SSE frame / HMI | did anything move? | Tags.Generation() vs the last rendered frame |
| Anything keyed by tag NAME | did a tag appear? | Tags.NameGeneration() |
The public surface is four methods on runtime.Tags — Generation(),
NameGeneration(), TagGeneration(name), and SnapshotInto(dst), which
refills your map with Sample{Value, Gen} instead of allocating a fresh
whole-store copy per tick. Snapshot() and All() are unchanged; prefer
the generation-aware forms in anything that runs on a clock.
Two consequences are worth knowing about:
WriteOutputs is a push of changes, not a per-scan heartbeat. The
runtime calls it on the first scan, on any scan where an output tag’s value
differs from the one it last handed over, after a failed write, and after a
redundancy takeover — and skips it otherwise. Every driver in tree already
reduced the call to that internally; now the call itself is skipped. A
driver that genuinely needs a per-scan call — a watchdog to re-arm, a bus
whose outputs decay without a rewrite — asks for one with
runtime.Options{AlwaysWriteOutputs: true}.
A driver can skip the per-scan input map. ReadInputs returns a fresh
io.Values every scan; on a driver with thousands of bindings that is tens
of kilobytes of garbage per scan for a map whose keys never change. A driver
may also implement io.BatchReader — ReadInputsInto(dst io.Values) error
— and the runtime hands it the same map back each scan. It is never
required, and the runtime falls back to ReadInputs when it is absent.
The change detection is deliberately conservative: two struct values are
equal only if they share the same StructDef, and a function-block instance
is never equal to anything (an FB is identity — its retained frame — not a
value). The only mistake it can make is to report a change that did not
happen, never to hide one.
The same counter is what makes an SSE delta stream possible: a client’s
entire subscription state is one uint64, and “what changed since you last
heard from us” is an integer comparison per tag with no value comparison
anywhere. Tags.ChangedSince(gen, dst) appends exactly the tags that moved
and hands back the generation to ask with next time. See
Streaming and data quality.
What a value is worth: quality
Section titled “What a value is worth: quality”The store holds values, not statements about values. Whether a reading is current, held over from a source that went away, or from a device that has never answered is not a property of the number — it comes from the driver, and it travels beside the tags rather than inside them.
A driver may implement io.QualityReporter; the runtime adds its own
derivation (a driver-bound input whose last read failed is stale); and the
server puts the non-good entries on /api/state and every stream frame
as a quality map. An absent name means good, so a healthy plant pays
nothing for it. The full model — the four values, where each comes from,
and why an HMI must check /api/meta’s capability flag before drawing a
quality badge — is in
Streaming and data quality.