Self-extension: teaching TIATool what it cannot do yet
TIATool ships with a fixed set of tools, but TIA Portal Openness can do far more than any fixed set. Self-extension closes that gap: when no tool does what you need, the AI explores the live Openness object model, writes a small C# script, runs it read-only, runs it for real only after you approve it, verifies the result, saves the script as a reusable extension and writes down what it learned. The next session finds the extension and the lesson.
The aim is "if Openness can do it, TIATool can do it — safely". Every change still goes through the same rails as the built-in write tools: policy, licence, a person's approval, backup, transaction, journal and the verify gate.
Status. The built-in extensions are compiled on every build against our Openness reference stub, but they stay unverified until the live selftest (
tiax selftest --live) has run on your machine: step 21 proves that a read script rolls back and a write script commits, step 22 compiles every built-in extension against your realSiemens.Engineeringassemblies and runs the read-only ones on a scratch project. Treat their first result on your TIA version with healthy suspicion.
The loop
| Step | Tool | What happens |
|---|---|---|
| 1. Reuse | extension_list, extension_show |
Search the library first: ~40 live extensions and 10 offline ones ship built in. |
| 2. Explore | openness_browse |
Walk the object model of the open project: attributes, compositions and actions with their exact names and types. |
| 3. Script | script_check |
Compile the C# script; errors come back with line numbers. |
| 4. Read run | script_run mode=read |
Runs inside an Openness transaction that is never committed and never saved. |
| 5. Write run | script_run mode=write |
Only after a person approves it (see below): backup, transaction, commit only on success, save, journal. |
| 6. Verify | export_project → refresh_scan → verify_change |
The post-write pipeline proves exactly the intended change happened. |
| 7. Promote | extension_save |
The working script becomes an extension with a description, parameters and provenance. |
| 8. Learn | knowledge_add |
Every pitfall met on the way is recorded with the verbatim error, cause and fix. |
Questions that the scanned cache can answer never need TIA Portal at all: use cache_query
(JavaScript over the .tiax cache) instead of a live script.
Clients that support MCP prompts can start the whole loop with the extend_tiatool prompt
(arguments: goal, optional project). It tells the model the order above and the rules that
go with it — reuse before writing, never guess a member name, read before write, confirm writes,
verify, save, learn.
Safety model
- Licence. Every tool needs an active licence. Listing and showing extensions, offline queries
and
knowledge_addonly read; live scripts and extensions follow the rules of the live tools, and anything that changes a project needs the write feature. - Read mode is a promise. A read run happens inside an Openness exclusive access and
transaction that is rolled back; the project is never saved. A script that tries to change
something in read mode is stopped by
ctx.RequireWrite(...). - Write mode is explicit and approved by a person.
script_run mode=write(and an extension whose manifest declares"mode": "write") needs a person's yes, which the AI cannot give itself: an AI client that supports MCP elicitation shows you the plan as a form (the model never sees a token); otherwise the result names a request and you runtiax-agent approve <id>in a terminal, then let the assistant repeat the call. The oldconfirm_tokenround trip is used only when a policy sets"confirm_mode": "in_band". After the approval the run takes a new timestamped backup, commits the transaction only when the script returns without an error, saves, journals (with the approver and the code's SHA-256) and runs the verify gate. - Policy. An administrator can limit scripts with
"allow_scripts": "off" | "read" | "write"in the policy file (security/policy.md);readkeeps write runs out. - Guard rail, not a sandbox. Scripts are checked against a denylist: processes, registry,
network, loading or emitting code (
Assembly.Load*,Reflection.Emit),AppDomain(exceptAppDomain.CurrentDomain.GetAssemblies(), to find an Openness type by name),Environment.Exit, writing, copying, deleting or moving files and folders (scripts may read files; Openness exports still work), pipes and memory-mapped files, threads, timers and background tasks,dynamic, native interop (DllImport,Marshal), unsafe code, COM activation, and saving, closing or opening projects (TIATool does that). Every script run is also bracketed by a check of the approval and policy files: if one appears or changes during the run it is restored, the run is refused and the journal recordsscript_tamper. Cache queries run in a JavaScript engine without .NET access and with time and memory limits. That keeps honest mistakes from doing damage; it is not a security boundary against a hostile script. Read a script before you approve a write run, as you would read a colleague's change. - Honest status. Everything self-made starts
unverified. Nothing is called proven until it has run against a real project.
Exploring the object model
openness_browse lists what an Openness object offers. Paths follow the object model:
Devices["PLC_1"]/DeviceItems[1]/... a device, then its first device item, and on
plc:PLC_1/Blocks["Main"] shortcut: the block Main of the PLC named PLC_1
Each answer gives the attributes (name, type, read/write access), compositions and actions of the object, which is exactly what a script needs. See the tool description for the full syntax.
Writing a script
A script is one C# file (C# 12 syntax compiled for .NET Framework 4.8 — it runs inside the TIA
engine process). The common System, Newtonsoft.Json.Linq, TiaTool.Scripting and
Siemens.Engineering namespaces are imported for you, and a small Tia helper class is compiled
in: Tia.Project(ctx), Tia.Devices(ctx), Tia.Plcs(ctx), Tia.Plc(ctx, name) (refuses an
ambiguous name), Tia.Hmis(ctx), Tia.Blocks(plc), Tia.FindBlock(plc, name), Tia.TagTables(plc)
— all of them walk device groups too.
using Siemens.Engineering;
using TiaTool.Scripting;
public static class Script
{
public static object Run(ScriptContext ctx)
{
var project = (Project)ctx.Project;
return new { name = project.Name, devices = project.Devices.Count };
}
}
ScriptContext gives you:
| Member | Meaning |
|---|---|
ctx.Project, ctx.Portal |
The open Siemens.Engineering.Project / TiaPortal (cast them). |
ctx.Arg<T>(name, fallback) / ctx.Require<T>(name) |
Arguments; a missing required one is reported as a usage error. |
ctx.RequireWrite("what") |
Call before the first change: refuses in read mode with a clear message. |
ctx.Log(text) |
Lines returned with the result. |
ctx.ThrowIfCancelled() |
Call in long loops so a timeout stops the script cleanly. |
ctx.Mode, ctx.ReadOnly, ctx.TiaVersion, ctx.ProjectPath |
Run information. |
The return value is serialized to JSON: anonymous objects, lists and dictionaries work. Return data, not prose.
Rules that keep scripts working across TIA versions:
- Use only members you saw in
openness_browseor in the Openness documentation. Read optional scalar properties by name withGetAttribute("Name")insidetry/catch, so a version without the property yields "not exposed" instead of a crash. - Do every check (does it exist, is the name free, is the language valid) before
ctx.RequireWriteand the first change. - Walk devices in device groups too (
project.DeviceGroups), not onlyproject.Devices. - Never read secrets (passwords, keys) — not even to display them.
Example 1 — read: watch and force tables per PLC
using System.Collections.Generic;
using Siemens.Engineering;
using Siemens.Engineering.HW;
using Siemens.Engineering.HW.Features;
using Siemens.Engineering.SW;
using Siemens.Engineering.SW.WatchAndForceTables;
using TiaTool.Scripting;
public static class Script
{
public static object Run(ScriptContext ctx)
{
var project = (Project)ctx.Project;
var result = new List<object>();
foreach (var device in project.Devices)
foreach (var item in device.DeviceItems)
{
var sc = item.GetService<SoftwareContainer>();
var plc = sc != null ? sc.Software as PlcSoftware : null;
if (plc == null) continue;
var tables = new List<object>();
Walk(plc.WatchAndForceTableGroup, "", tables);
result.Add(new { plc = plc.Name, tables = tables });
}
return result;
}
private static void Walk(PlcWatchAndForceTableGroup g, string path, List<object> into)
{
foreach (var t in g.WatchTables) into.Add(new { kind = "watch", group = path, name = t.Name, entries = t.Entries.Count });
foreach (var t in g.ForceTables) into.Add(new { kind = "force", group = path, name = t.Name, entries = t.Entries.Count });
foreach (var s in g.Groups) Walk(s, path + s.Name + "/", into);
}
}
(The built-in watch_tables_list is this script plus device groups and nested device items.)
Example 2 — write: create a PLC tag
using System;
using System.Linq;
using Siemens.Engineering;
using Siemens.Engineering.HW.Features;
using Siemens.Engineering.SW;
using TiaTool.Scripting;
public static class Script
{
public static object Run(ScriptContext ctx)
{
var project = (Project)ctx.Project;
var name = ctx.Require<string>("name");
var plc = project.Devices.SelectMany(d => d.DeviceItems)
.Select(i => i.GetService<SoftwareContainer>()).Where(s => s != null)
.Select(s => s.Software).OfType<PlcSoftware>().Single();
var table = plc.TagTableGroup.TagTables.First(t => t.IsDefault);
if (table.Tags.Find(name) != null) throw new ScriptArgumentException("name", "already exists");
ctx.RequireWrite("create PLC tag " + name); // nothing has changed before this line
var tag = table.Tags.Create(name, ctx.Require<string>("data_type"), ctx.Require<string>("address"));
return new { table = table.Name, name = tag.Name, address = tag.LogicalAddress };
}
}
Run it with mode=read first: it checks everything and stops at RequireWrite with a message
saying what it would do. Then run it with mode=write and confirm.
Example 3 — an API that only some TIA versions have
Some Openness services (software units, user management, cross-references) do not exist in every
version. Look them up by name at run time, so the script compiles everywhere and answers honestly
(AppDomain.CurrentDomain.GetAssemblies() is the one AppDomain call the guard rail allows):
using System;
using System.Linq;
using Siemens.Engineering;
using TiaTool.Scripting;
public static class Script
{
public static object Run(ScriptContext ctx)
{
var project = (Project)ctx.Project;
var type = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name.StartsWith("Siemens.Engineering", StringComparison.Ordinal))
.Select(a => a.GetType("Siemens.Engineering.Umac.UmacConfigurator", false))
.FirstOrDefault(t => t != null);
if (type == null) return new { available = false, reason = "no UMAC service in this Openness version" };
var service = typeof(IEngineeringServiceProvider).GetMethod("GetService").MakeGenericMethod(type).Invoke(project, null);
return new { available = service != null };
}
}
Extensions
An extension is a folder:
extensions/<id>/
extension.json the manifest
script.cs kind "script": runs live in TIA Portal (extension_run)
query.js kind "cache": runs offline on the .tiax cache (cache_query extension=<id>)
extension.json (tiatool.extension/1):
| Field | Meaning |
|---|---|
id |
^[a-z][a-z0-9_]{2,47}$, unique; the folder name. |
title, description |
What the AI reads to choose it — say precisely what it returns or changes. |
category, tags |
Grouping and search words (extension_list category= / query=). |
kind |
script (live) or cache (offline, always read-only). |
mode |
read or write. Anything that changes the project is write. |
parameters |
JSON Schema (type object) of the arguments; arguments are checked against it. |
min_tia |
Lowest TIA Portal version it is written for, e.g. V17. |
status |
unverified until a live selftest ran it; then selftest_verified / proven. |
provenance |
Set by extension_save: where the script came from (session, journal id, assembly hash). |
Override chain (the same as equipment templates): built-in → %APPDATA%\TIATool\extensions
→ <workspace>/extensions. A folder with the same id further down the chain replaces the one
before it, so you can fix or adapt a built-in for one project without touching the installation.
A broken override is reported by extension_list and the previous definition stays in use.
Offline queries: cache_query
cache_query runs JavaScript (ES2023, strict mode) over the scanned cache in tiax-agent. Pass
code, or extension=<id> with args to run a cache extension.
// every FB that writes more than 20 symbols
tia.edges({ type: 'FB' }).filter(b => b.writes.length > 20).map(b => ({ name: b.name, writes: b.writes.length }))
The result is the value of the last expression — or, when the code defines function run(args),
what run returns (that is the shape of a cache extension's query.js). log(...) lines come
back with the result.
| Call | Returns |
|---|---|
tia.project() |
project.json of the scan: PLCs, HMI devices, counts, fidelity. |
tia.manifest() |
Every manifest row (blocks, tag tables, HMI artifacts). |
tia.blocks({type, lang, plc, name}) |
PLC manifest rows: id, name, type, lang, plc, number, networks, fidelity, ... |
tia.block(name) / tia.record(id) |
The full record: interface tree, networks with scl, reads, writes, calls. |
tia.edges({type, plc, name}) |
Compact per block: name, type, plc, lang, fidelity, networks, scl_chars, members, writes, reads, calls, uses_types. Use it for whole-project questions. |
tia.tags({plc, table, name}) |
PLC tags {name, type, address, comment, table, plc}. |
tia.constants({plc, table, name}) |
User constants {name, value, table, plc}. |
tia.whoWrites(symbol, exact) / tia.whoReads(symbol, exact) |
Sites {block, network, title, direction, confidence, scl}. |
tia.callers(block) / tia.calls(block) |
The call graph from CallInfo. |
tia.unreachable() |
Code blocks no OB reaches. |
tia.search(text, limit) |
Ranked rows across blocks and HMI. |
tia.screens(device) / tia.screen(name) |
HMI screens. |
tia.hmiTags(filter) |
HMI tags with table, connection and the screens using them. |
tia.hmi(type, device) |
HMI manifest rows of a type, e.g. 'TextList', 'HmiTagTable'. |
args |
The arguments object (frozen). |
Limits: arrays above 5 million elements and strings above 16 million characters are refused
before they are allocated; 5 s of script time and 64 MB of script allocations (the time and memory the host
spends loading cache records do not count), 30 s wall clock, 10 million statements, recursion
depth 256, a result of at most 1 million characters (return summaries, not whole records) and 200
log lines. The engine has no file, network or .NET access and no eval.
The answers are as good as the cache: STL and GRAPH bodies record no read/write edges, LAD/FBD
renderings marked lossy are incomplete, and HMI-to-PLC matches are by name only (the export does
not carry the link).
Learning: knowledge_add
When a script or tool fails and you find out why, record it:
knowledge_add title="Watch table entries need the quoted symbol"
symptom="<the verbatim error text>"
cause="<what was proven>" fix="<what worked>" evidence="<calls, journal id, TIA version>"
The entry is written to <workspace>/knowledge/learned/<slug>.md (without a workspace:
%APPDATA%\TIATool\knowledge\learned) in the same format as the shipped knowledge base, with a
Symptom block holding the verbatim error. From then on explain_error and the error answers of
every tool match the same error text, and the entry is listed as a tiatool://knowledge/workspace/…
resource marked learned, not reviewed. Entries that prove themselves belong in the shipped
knowledge base: copy them into knowledge/ and review them like any other change.
The built-in pack
| Category | Extension | Kind / mode | What it does |
|---|---|---|---|
| Project | project_properties |
script / read | Author, comment, copyright, family, version, times, size. |
| Project | project_languages |
script / read | Editing, reference and active project languages. |
| Project | project_inventory |
script / read | Devices, subnets, and per PLC / Classic HMI the count of every kind of object. |
| Devices and catalog | device_inventory |
script / read | Devices and modules with position, type identifier, order number and firmware. |
| Devices and catalog | plc_overview |
script / read | Per PLC: CPU order number and firmware, block / tag / type counts, protected and inconsistent blocks. |
| Devices and catalog | catalog_search |
script / read | Hardware catalog search: order number, version, type identifier. |
| Network | network_interfaces |
script / read | Every interface node: subnet, IP address, mask, router, PROFINET name, device number. |
| Network | network_subnets |
script / read | Subnets with type, connected nodes and IO systems. |
| Network | io_systems |
script / read | IO systems with controller and IO devices. |
| Network | network_set_ip |
script / write | Set the IP address (and mask) of a device's Ethernet node. |
| Watch and force tables | watch_tables_list |
script / read | Watch and force tables per PLC with entry counts. |
| Watch and force tables | watch_table_read |
script / read | The entries of one watch or force table. |
| Watch and force tables | watch_table_create |
script / write | Create a watch table with entries (or append to one). |
| Text lists | hmi_text_lists |
script / read | Text and graphic lists of every Classic HMI. |
| Text lists | hmi_text_list_entries |
cache / read | Text-list entries per language and missing translations. |
| Technology objects | technology_objects_list |
script / read | Axes, PID controllers, cams, counters per PLC. |
| Technology objects | technology_object_parameters |
script / read | Parameters of one technology object. |
| Technology objects | technology_object_set_parameter |
script / write | Set one technology-object parameter. |
| Software units | software_units_list |
script / read | Software units of a PLC (where the TIA version has them). |
| Libraries | library_types |
script / read | Project or global library types with every version and state. |
| Libraries | library_master_copies |
script / read | Master copies with folder path. |
| Libraries | global_libraries |
script / read | Known and open global libraries. |
| Libraries | library_type_instances |
script / read | Type instances in the project and the ones not on the newest released version. |
| HMI | hmi_connections |
script / read | HMI connections (Classic and Unified), no secrets. |
| HMI | hmi_screens_list |
script / read | Screens, templates and pop-ups (Classic) and screens (Unified). |
| HMI | hmi_alarm_classes |
script / read | Alarm classes of Unified HMIs. |
| Security (UMAC) | umac_users_roles |
script / read | Users, groups and roles, read-only, no secrets. |
| PLC tags and constants | tag_tables_list |
script / read | Tag tables with group path and counts. |
| PLC tags and constants | tag_table_read |
script / read | One tag table with types, addresses, comments and External* flags. |
| PLC tags and constants | tag_create |
script / write | Create a PLC tag. |
| PLC tags and constants | tag_update |
script / write | Change a tag's name, type, address, comment or External* flags. |
| PLC tags and constants | tag_delete |
script / write | Delete a PLC tag (returns what it was). |
| PLC tags and constants | tag_table_create |
script / write | Create a tag table in a group path. |
| PLC tags and constants | tags_by_address_range |
script / read | Tags inside an address range, e.g. %I0.0..%I7.7. |
| PLC tags and constants | tag_address_overlaps |
script / read | Tags whose addresses overlap. |
| PLC tags and constants | user_constants_list |
script / read | User constants with type and value. |
| PLC data types | udt_list |
script / read | UDTs with consistency, protection and library version. |
| Blocks | block_protection_status |
script / read | Know-how protected blocks and counts per type. |
| Blocks | block_language_census |
script / read | Blocks by language, type and memory layout. |
| Blocks | block_numbers |
script / read | Used and next free block numbers per number space. |
| Blocks | block_consistency |
script / read | Inconsistent blocks and UDTs. |
| Blocks | block_attributes |
script / read | Every exposed property of one block. |
| Blocks | block_groups |
script / read | The program-block folder tree with counts. |
| Cross-reference | cross_reference |
script / read | Live TIA cross-reference of one tag or block (where the TIA version has it). |
| Offline analysis | unused_tags |
cache / read | PLC tags no block or HMI uses (by name). |
| Offline analysis | blocks_by_language |
cache / read | Blocks by language, type and cache fidelity. |
| Offline analysis | largest_blocks |
cache / read | The biggest code blocks. |
| Offline analysis | write_conflicts |
cache / read | Symbols written by more than one block. |
| Offline analysis | alarm_text_coverage |
cache / read | Alarm-like DB bits without a comment. |
| Offline analysis | hmi_tags_without_plc |
cache / read | HMI tags whose name matches nothing on the PLC side. |
| Offline analysis | naming_violations |
cache / read | Names that break your naming rules (regex per kind). |
| Offline analysis | call_depth |
cache / read | The deepest call chain below every OB. |
| Offline analysis | udt_usages |
cache / read | Blocks and tags that use a UDT. |
extension_list always shows the live library, including your own extensions and overrides.
Limits and honesty
- Compiling against our reference stub proves a script matches our description of the Openness API. Only the live selftest proves it matches yours.
- A write extension is a write: it is approved by a person, backed up, journaled and verified like any other change. Read the script before approving it.
- The script guard rail and the query limits catch mistakes, not attackers.
- Learned knowledge is written by an AI and marked as such until a person reviews it.
Built from docs/self-extension.md in the product repository. Links to files that are not published here are shown as plain text.