Skip to main content

The CLI's WebDev routes (ign webdev)

Every tag operation the CLI performs on a gateway rides the CLI's own WebDev route bundle — small Jython doPost.py routes deployed into a DEDICATED project (ign-cli by default) under /system/webdev/ign-cli/cli/{route} (the wire protocol — NOT /data/webdev/*, which does not exist). The bundle is embedded in the binary at build time and carries a ROUTE_BUNDLE_VERSION handshake ({"action":"version"} → {routeVersion, minCli}) on every route.

Deploy semantics​

ign webdev deploy packs the embedded bundle into a project zip and imports it with overwrite=true — a clean replace, never a merge. The dedicated project is CLI-OWNED: it is born from the first deploy zip and wholesale-replaced by every later deploy, which is why deploy carries no --yes guard (user projects are never touched; you cannot accidentally deploy over anything you own — use --project only if you deliberately want a different name). There is no pre-flight project create (the gateway's first-import quirk); the import is the deployment.

The version-negotiation refusal matrix​

WebDev-dependent commands (the tag family, from Phase 5 on) probe the canonical tags route's handshake BEFORE doing anything, and refuse exit 6 with an actionable error — no auto-upgrade magic, ever:

Probe answerSlugHint
405 (route/project absent)routes_not_deployedrun ign webdev deploy
deployed < expectedroute_version_mismatchrun ign webdev deploy (old routes on the gateway)
deployed > expectedroute_version_mismatchupdate ign (the binary is older than the routes)
402webdev_unlicensedlicense the gateway (trial-expired rigs cannot serve /system/webdev)
200 body denial, unmapped codewebdev_route_errorthe route's own code + message verbatim

Two wire facts the matrix is built on (live-proven on 8.3): denials ride HTTP 200 — WebDev ignores a status key in route returns, so every refusal is detectable only from the body envelope {ok, data|error}; and 405 = absent (missing routes and missing projects both answer 405 — ign doctor's earlier 404 assumption was wrong and has been re-pinned).

The route-authoring contract (live-bisected 05-06): every doPost.py must begin with def doPost(...) at BYTE 0. Any module-level content before it — comments, constants, even a blank line — makes the route silently unloadable on the real WebDev engine (the servlet answers 200 with an empty body; nothing is logged). The route sources keep their header docs and ROUTE_VERSION/MIN_CLI constants NESTED inside doPost for exactly this reason. A second, related engine quirk: the exec(...) CALL form can fail to compile at deep nesting on this Jython build — use the statement form (exec code in globals) in route scripts.

ign webdev status itself is a READ: it exits 0 whenever the sweep completes and reports per-route degradation as data (the doctor precedent) — the refusal matrix above belongs to the tag commands that DEPEND on the routes, not to the sweep that inspects them.

The configure-shape traps (authoring tag definitions)​

tags config create|edit definitions ride system.tag.configure verbatim — the CLI never reshapes them. Four traps reproduced live during Phase 5 research (each one silently breaks or misleadingly errors on a real 8.3 gateway — author definitions against this table):

TrapWrongRight
Discriminator key"type": "AtomicTag""tagType": "AtomicTag" (type is silently ignored)
Nestingslash-names ("name": "P5/T1") — rejectedchildren NEST under a tags array on Folder/UdtType entries
Alarmsa name-keyed dict — silently ignored (returns Good, no alarm attached)alarms are a LIST of dicts: "alarms": [{"name": "HighLimit", "mode": "AboveValue", "setpointA": 100, "priority": "High"}]
Base patha provider name as configure's first arga basePath ([default]P5) + per-tag names — the CLI derives both from the PATH you pass, so this trap cannot bite via the CLI

A minimal memory tag definition: {"tagType": "AtomicTag", "dataType": "Int4", "value": 123}.

Tag↔historian bindings — the surfaced field set (TAGS-13)​

When a tag's config carries history-binding keys, tags config get human mode appends an additive history: block after the pretty JSON — one line per present field. The key names are CAPTURE-LOCKED to the 13-01 live spike record (both trial rigs, read-back + data proof; provenance: .planning/phases/13-composite-engine-workspace-historian-edit/13-LIVE-CAPTURES.md §Field-set table). No name below is guessed:

Config keyTypehistory: lineMeaning
historyEnabledBooleanenabled: trueThe binding switch
historyProviderStringprovider: <name>The storage provider's name (the doc-corrected key — historicalProvider is a documented typo that does NOT bind)
sampleModeEnumsample mode: TagGroupThe capture enum VERBATIM ("TagGroup" binds via the gateway's DEFAULT historical group — no group key is needed and none is guessed)
— (no captured key)—historical group: …Rendered only if a key is ever captured; the spike bound without one

Additive-only contract: a config WITHOUT history keys renders byte-identically to the pre-TAGS-13 output (pinned by test), and the compact/JSON envelope is unchanged (the full config passthrough already carries the keys there).

The binding recipe (TAGS-14, live-proven on both trial rigs): binding a tag to a storage provider is a plain tag-config write — the CLI's existing tags config create|edit passthrough needs NO new surface. Provision an InternalHistorian first (native REST, no database: POST /data/api/v1/resources/com.inductiveautomation.historian/historian-provider with the array body [{name, type: "com.inductiveautomation.historian/historian-provider", collection: "core", enabled: true, config: {profile: {type: "InternalHistorian"}, settings: {}}}]), then send the COMPLETE node shape plus the three history keys in ONE write:

{"tagType": "AtomicTag", "dataType": "Int4", "value": 0,
"historyEnabled": true, "historyProvider": "my-historian",
"sampleMode": "TagGroup"}

Two wire truths make the "complete node shape" mandatory (both from the 13-01 captures): (1) tags config writes REPLACE the whole node — an edit that sends only the history keys silently drops dataType/defaultValue and nulls value; (2) NO historical-group key is needed — at sampleMode: "TagGroup" the gateway binds its DEFAULT historical group. Follow-through: write tag values, then ign tags history query — the tag-group scan stores at 40–50 s cadence, so allow a couple of scans before expecting the written values as DATA rows (live-proven on 8.3.6 AND 8.3.3; the e2e gate live_tags_history_bindings re-proves the full chain). Provider cleanup rides the generic resources API: find GET /data/api/v1/resources/find/{module}/{type}/{name} → signature → DELETE /data/api/v1/resources/{module}/{type}/{name}/{signature}.

Bulk export/import (the portability loop)​

tags export → tags import moves a tag subtree between providers (or gateways) with values intact. --format json (both sides' default) is the gateway's own JSON interchange, parsed and written pretty — the lossless round-trip. --format xml passes the gateway's own XML document through RAW-BYTE both directions (export: the gateway's exportTags format=xml answer decoded verbatim; import: your bytes ride base64 to the gateway's importTags untouched — the gateway's parser is the only semantic actor). The collision conventions are IDENTICAL to project import's (locked in Phase 3): abort pre-checks and refuses before any write; overwrite is --yes-guarded with no pre-check. export -o - pipes into import --file - in any format.

The CSV contract (read this before --format csv)​

The gateway cannot export tags to CSV (official Ignition 8.3 docs: "Ignition does not export tags to a CSV format"). ign tags export --format csv output is CLI-generated from the gateway's JSON interchange and is LOSSY:

  • No alarm configurations — the legacy CSV grammar cannot carry them; alarm data in the source is dropped (live-proven: even a CSV imported with the undocumented AlarmStates column lands nothing).
  • The legacy column set only — modern properties (tag groups, bindings, UDT parameter overrides, scan-class-adjacent and history-retention columns) have no legacy column and are dropped.
  • Numeric enum coercion — TagType/DataType/ExpressionType ride legacy numeric codes; folders flatten (the legacy Path column is unusable — any non-empty value crashes the gateway's importer wholesale, so every row carries an empty Path).

The command prints exactly what was dropped on stderr (warning: csv export dropped field(s): …) and carries the structured twin in data.loss_report ({format, dropped_keys, coerced, rows}). Generation is warn-and-continue, never gated (nothing destructive happens — no gate applies); the column-by-column coverage table (what lands, coerces, drops, or crashes) is documented in .planning/phases/11-tag-bulk-transfer-xml-csv/11-LIVE-CAPTURES.md §Probe 5, live-proven on 8.3.3 and 8.3.6.

The loss gate (imports, TAGS-12)​

When importing xml/csv, the CLI SCANS the input before anything is sent and reports what the import would drop or coerce — UDT type definition transfer caveats (definitions DO import on this command's provider-root path, routing to [provider]_types_/Name; but exported udtParentType stays provider-qualified, so cross-provider imports keep pointing at the source provider, and instance parameter overrides silently drop when the referenced type is unresolvable in the target provider on 8.3.3), gateway-export-shaped files carrying only edited properties, and (for CSV, unconditionally) the no-alarms/legacy-columns facts. If the scan reports losses, the CLI prints the loss report as prose on STDERR and refuses exit 2 invalid_input unless --yes — the refusal happens before any network activity (profile null, zero requests). With --yes the import proceeds and the structured scan summary rides the success envelope as data.loss_report ({format, facts, top_level_names}), so agents can read the same findings without parsing prose. The scan is advisory — the gateway remains the parsing authority; a clean scan never blocks an import, and a gateway-side parse error still surfaces as the gateway's own error.

Collision policy (xml/csv)​

The gateway's importTags also accepts an Ignore collision policy ('i') that silently keeps existing tags. ign deliberately surfaces only abort and overwrite (the same two-value matrix as tags import's JSON path since 03-02): abort is the safe default with a real pre-check, overwrite is honest about being destructive and requires --yes — a silent-ignore policy has no honest CLI story, so it is not a value.

Live-proven payload shapes (05-06): the gateway's exportTags never answers a bare array — one path yields a SINGLE subtree object, several yield the {"tags": [...]} wrapper. tags export NORMALIZES both to the list-of-subtrees interchange (what the export file carries and tags import consumes). A provider-shaped export subtree (empty name) lands its CHILDREN at the import target — the abort pre-check and the imported count therefore key on the EFFECTIVE top-level names, and the structural _types_ folder (present on every provider; the server's own abort policy accepts configuring it) never counts as a collision.

Provider-ROOT paths are not supported on 8.3 WebDev threads — system.tag.getConfiguration/exportTags need an RPC context the WebDev route's threads don't carry (live-proven 8.3.3 b2026012009 on both devops-rig gateways; ign tags export [default] and ign tags export default both hit it). The deployed route refuses honestly — exit 6 provider_root_unsupported with the subtree workaround in the message and hint — where the bracket form is detected before the call and the bare form (a leading provider-matching segment) is translated from the gateway's own No RpcContext throw. Subtree paths ([default]folder) are the supported form and behave exactly as documented.

Browsing tag exports offline (--from-export)​

ign tags browse --from-export <PATH> reads a tag export WITHOUT a gateway — no profile resolution, no credential, no deployed routes (the envelope carries profile: null; the positional browse path and the flag are mutually exclusive). Three layouts are accepted:

  1. The CLI's own export — the JSON file ign tags export -o writes (the list-of-subtrees interchange). The provider is the file stem (default.json → [default]… rows).
  2. A legacy whole-tree file — a <provider>.json carrying the entire provider (the pre-individual-file git-module format). Provider = file stem.
  3. A git-module directory — the layout ignition-git-module checks in: a tags/ root (or the directory itself when it directly holds provider folders/.json files), one folder or legacy <provider>.json per provider. In the individual-file format, folders are DIRECTORIES, each leaf tag is one .json (the tag's name field is stripped — the filename is the name, %XX-decoded for reserved characters), and _types_/*.json at the provider root are the UDT definitions. Dot-entries skip (the module's own rule), .tag-config.json is config (not a provider), and the System provider is always excluded.

The rows ride the SAME renderer and flat JSON shape as the live browse ({path, name, tag_type, has_children, data_type} with bracketed fullPaths) — --filter applies client-side. Offline errors (missing path, unparseable JSON) exit 2 invalid_input before any network. The natural pipe: browse an export from one gateway's git module, then promote it with ign tags import --provider <prov> --file <interchange> (or the README's cross-profile pipe).

Alarms and tag history​

The alarms + tagHistory routes close the tag surface (TAGS-07/08). The alarm lifecycle is live-proven end-to-end (configure a LIST-form alarm → write past the setpoint → alarms active shows Active, Unacknowledged → alarms ack → the state flips to Active, Acknowledged):

  • alarms active filters ride system.alarm.queryStatus kwargs verbatim; quality/state strings are data, never parsed.
  • Alarm history needs a JOURNAL — and default rigs have none. alarms history on an unprovisioned rig refuses exit 6 (alarm_journal_missing) naming the missing chain — the honest, actionable default-rig path (live-proven). The provisioning chain is: (1) a running database reachable from the gateway (e.g. a sidecar postgres container), (2) a ignition/database-connection resource pointing at it, (3) an ignition/alarm-journal profile referencing that connection, and (4) the ignition/general-alarm-settings singleton pointed at the journal profile. All four steps are native config-resource REST (the same resource family as tag providers) — provisionable headlessly once a database exists; the e2e gate leaves this as a documented stretch (the wiremock pins carry the capability proof).
  • alarms ack is the gateway-scope 3-arg form (String[] ids, note, username) — the explicit --username is required, and the unacknowledged REMAINDER comes back as data (acknowledged = requested − remainder, computed client-side honestly).

Tag history (TAGS-08): tags history query works STRUCTURALLY on any rig (a well-formed t_stamp-keyed dataset, null values without a historian — exit 0). DATA requires a provisioned historian: an InternalHistorian needs no database — creatable via native REST (POST /data/api/v1/resources/com.inductiveautomation.historian/historian-provider, profile type InternalHistorian; the e2e gate provisions one live). The tag↔historian binding is CLOSED (13-01 spike, both rigs): the Phase-5 "documented limitation" was a key-name typo — historicalProvider never bound; the captured field set historyEnabled + historyProvider + sampleMode: "TagGroup" (discussed in Tag↔historian bindings above) landed, read back, and returned WRITTEN values from history on 8.3.6 AND 8.3.3. The e2e gate live_tags_history_bindings re-proves the full chain on licensed trial rigs.

Phase 5 requirement map​

RequirementShipped as
WEB-01 (route bundle + deploy)ign webdev deploy (+ the embedded 13-member bundle)
WEB-02 (status sweep)ign webdev status
TAGS-01 (provider CRUD)ign tags provider list/create/delete
TAGS-02 (browse)ign tags browse
TAGS-03 (read)ign tags read
TAGS-04 (write)ign tags write
TAGS-05 (config CRUD)ign tags config get/create/edit/delete
TAGS-06 (UDTs)ign tags udt types/def
TAGS-07 (alarms)ign tags alarms active/history/ack
TAGS-08 (tag history)ign tags history query
TAGS-09 (bulk transfer)ign tags export/import

Parity with the 21-tool MCP tag-domain surface is complete (intent-corrected, not bug-replicated — the prior-art defects the research found are corrected in the routes).

scriptExec — the LOCKED security posture​

scriptExec (arbitrary Jython execution through the gateway) ships in the bundle only as a TEMPLATE with a __IGN_CLI_SECRET__ marker and deploys ONLY with an explicit --with-script-exec:

  • the deploy generates a fresh 32-byte hex secret from /dev/urandom, substitutes it into the template (the placeholder can never ship — it is excluded from the plain manifest), and persists it in the profile config at 0600; --rotate-secret regenerates (any route copy deployed with the old secret starts refusing);
  • the route fail-closes on every action — version included — unless the request carries the matching X-Ignition-CLI-Secret header: no header → secret_required, wrong secret → secret_mismatch (constant-time compare);
  • the secret appears in exactly ONE place: the baked zip member on the gateway. Never in command output, JSON envelopes, or logs.

Threat-model honesty: the secret gate is a SHARED-SECRET posture, not real authentication — anyone who can read the gateway project's resources (e.g. a Designer user or a project export) can read the secret. It exists to make scriptExec an opt-in, auditable surface with a definite off switch (never deploy it, or redeploy without the flag), not to protect against gateway insiders. The route keeps its config at require-auth=false deliberately: an auth layer would lock the CLI's own token-authenticated calls out (API tokens 401 on WebDev require-auth — live-verified).

The ign script run verb contract​

ign script run (07-03) is the route's entire consumer surface, and its security posture is the deploy flag — nothing more:

  • Input forms: --code PY (inline — a one-liner's best form), --file PATH, --file - (stdin — the agent pipe path). Giving both --code and --file, or neither, refuses invalid_input (exit 2) before any resolution work.
  • The opt-in is structural: a profile with no persisted webdev_secret can only mean the route was never deployed through the flag — the verb refuses exit 6 script_exec_not_configured with zero HTTP requests, its hint naming ign webdev deploy --with-script-exec verbatim. There is deliberately NO --yes guard here: the deploy flag is the opt-in, and requiring a second confirmation would only tax the non-interactive agents the route exists for.
  • Result shape: {stdout, result, elapsedMs} — all keys always (empty string / null / 0 when the answer carried nothing; agents never key-hunt). stdout is the script's captured output verbatim; result is a final expression's value or the _result global's statements; elapsedMs is the route-measured wall time.
  • Timeout honesty: v1.0.0's route has NO server-side execution timeout — a long-running script simply holds the HTTP connection while it runs (the client rides its existing per-request timeout class). Budget long scripts accordingly.
  • Redaction guarantee: the secret appears in exactly one place (the baked zip member) — never in the JSON envelope, the human render, logs, or any error path (the refusal and result canaries are contract-pinned at both the action and binary levels).
  • Threat-model cross-reference: the shared-secret honesty note above applies to every script run invocation — the gate makes the surface opt-in and auditable; it does not defend against gateway insiders who can read the project's resources.