Skip to content

The script interface

Scripts are Python. TrakState runs them in a process of their own — no database, no access to the plant’s file system, the network only as your installation allows it. Everything that goes in and out goes through the few names on this page — tags, the clock, the assistant, and the file store of your installation.

Everything TrakState offers hangs under system.. Type system. and the dot, and the editor shows you the rest. You do not have to know a single name to find out which ones exist — you have to know one word.

value = system.tag.read("Press1.Cycle")
system.tag.write("Press1.Setpoint", 42)

The grouping says something in itself: system.tag.* is the tag layer, system.time.* the clock.

Under system. there is only what comes from us. round(), json, datetime, str are Python’s and stay Python’s — there is no system.util.round and there will not be one. We do not wrap what stands in every Python handbook; system. would otherwise be a claim about what is ours, and you would search our help for something that will never be in it.

Blank stays what is your script’s contract: inputs, params, result, on_read, on_write. That is not a library you call, it is the shape in which your script offers itself. system.result would not be a better name for the same thing, it would be a false statement about what it is.

A typo therefore does not end in a riddle but in an answer to the question you actually have — what is there instead:

AttributeError: no 'raed' here - this namespace has: read, write

The former names read_tag and write_tag are gone, with nothing in their place and no transition period. A script that still calls them fails with a NameError. Replace them with system.tag.read() and system.tag.write().

Which shape you are in is decided by whether you write on_read or on_write. There is no setting for it.

No def on_read. Your code is the body of a function, so return works — and if you return nothing, the variable result is the answer.

# The return value is the tag's value.
return inputs["good"] + inputs["scrap"]
# The same thing without return:
result = inputs["good"] + inputs["scrap"]

A tag in this shape is read-only. An attempt to write it is refused, not silently dropped.

You write def on_read() and/or def on_write(value). The rest of your code then sits at module level and runs on every call, before the hook — the ordinary behaviour of a Python module.

INCH = 25.4
def on_read():
return inputs["length_mm"] / INCH
def on_write(value):
# Whatever the setter returns becomes this tag's own value.
system.tag.write("Press1.Length_mm", value * INCH)
return value

A tag with on_write is writable, one without is not. A script that has only on_write reads as nothing — it said what it does, and reading was not part of it.

The hooks are called on_read/on_write and not get/set, because set is a Python builtin. Shadowing it breaks a later set([1, 2]) in the same script.

Name Call Meaning
system.tag.read system.tag.read(path) Reads a tag right now, by its path. Fails loudly rather than returning None — a silently missing value becomes a wrong number further down.
system.tag.write system.tag.write(path, value) Asks for a value to be written. Nothing leaves the script: the host carries the writes out afterwards, or does not — which is exactly what a dry run is.
system.time.plant_now system.time.plant_now() The time on the clock the plant runs by, with its offset. Not the same as datetime.now(), which is the zone of the machine this runs on.
system.ai.ask system.ai.ask(prompt, data=None) Asks the language model and returns its answer as text. The figures belong in data — the model phrases, explains and suggests, it does not compute. Script objects only; every question counts against the AI budget.
system.file.list system.file.list(folder, pattern=None) The files of one folder of the file store (not its sub-folders), each a dictionary with id, folder, name, size, media_type, sha256, source, created_at. pattern is shell-style ("*.zip"), case ignored. Script objects only; every folder but Chat/.
system.file.read system.file.read(file_id) Copies a stored file into this run and returns the local path — open it like any file. Bounded per run in bytes and files; the copy is gone when the run ends.
system.file.write system.file.write(folder, name, bytes_or_path) Stores a file: bytes, or the path of a file the script wrote. Text needs .encode("utf-8") first — a str is read as a path. Not stored in a dry run.
system.file.move system.file.move(file_id, folder) Moves a stored file into another folder; its id stays the same. Not performed in a dry run.
system.file.claim system.file.claim(file_id, key="default") Takes a file for this run, atomically: True — import it; False — it was imported already (the same bytes count) or another run is importing it right now. Becomes processed when the run and all its writes succeed, released when it fails. The check an importer starts with.
system.file.processed system.file.processed(file_id, key="default") True when this file — or the same bytes under another name or after a move — was already processed under key, or another run holds a claim on it right now. A look, not a lock — decide with system.file.claim.
system.file.mark_processed system.file.mark_processed(file_id, key="default", note=None) Remembers that a file was handled. Collected like system.tag.write: filed after the run, and only when every write of the run went through.
system.push.send system.push.send(role, title, body, path=None, tag=None) Asks for a push notification to everyone holding a role — on their phone and their browser alike. Collected like system.tag.write: the host sends it after the run, or not at all in a dry run.
system.log.info system.log.info(text) A line for the diagnostics panel. In a run nobody started by hand it goes to administrators, with the script’s path - everyone else sees a short note that a warning or error was logged; in a manual run and in Try it out to whoever pressed the button (Try it out also shows them under the result). In a script tag they go to administrators with the tag’s path - a getter’s lines at most once a minute, since it runs on every read. Kept even when the run fails; at most 20 lines per run.
system.log.warn system.log.warn(text) Like system.log.info, at level warn — something went wrong and the script carried on.
system.log.error system.log.error(text) Like system.log.info, at level error. The way to report an error the script caught itself — an uncaught one throws away every write of the run.
inputs inputs["name"] Your bound inputs, under the names from the input list. The tracked way to depend on a tag.
params params["name"] What the trigger knew. A connection event hands the script the name of the connection that went down; File arrived hands file_id, folder and name of the new file.
result result = ... The value, when you do not return one. Plain shape only — a script that declares hooks answers through them.
on_read def on_read(): Produces the tag’s value. Declaring it moves your remaining code to module level, where it runs on every call, before the hook.
on_write def on_write(value): Receives a written value, and is what makes the tag writable at all. Whatever the hook returns becomes the tag’s own value.
print print(...) Goes to the run log, not into the result. The two channels are separate on purpose.

That is all of it from us. Everything else in your script is Python.

Files: importing deliveries without anybody clicking

Section titled “Files: importing deliveries without anybody clicking”

A delivery that comes again and again — a zip per production line every morning, an export a machine drops every shift — is imported by a script, not by hand. system.file reaches the file store (Settings › Files); the trigger File arrived starts the script for every new file in a folder, whoever put it there: a person on the Files page, a machine with an API token, the assistant, or another script.

import csv, io, zipfile
def run_import(folder):
imported = skipped = 0
for f in system.file.list(folder, "*.zip"):
if not system.file.claim(f["id"]):
skipped += 1
continue
with zipfile.ZipFile(system.file.read(f["id"])) as z:
for entry in z.namelist():
if entry.endswith(".csv"):
text = io.TextIOWrapper(z.open(entry), encoding="utf-8")
for row in csv.DictReader(text, delimiter=";"):
system.tag.write("Production.Records", {"Line": row["line"], "Pieces": int(row["pieces"])})
imported += 1
system.tag.write("Import.Status", f"{imported} rows imported, {skipped} deliveries already imported")

What a script may see: the whole file store except the private folders Chat/, Users/ and Sessions/. A script has no person behind it, and those hold what belongs to one person or one conversation — a script cannot list, read, write, move or claim anything there, a file id from them reads as “no such file”, and a File arrived trigger cannot watch them. Team/ and every other folder are shared.

Running twice is safe. system.file.claim() takes the file for this run in one atomic step and matches on the content: the same delivery uploaded again — under another name, or after the first copy was moved on — counts as done, and two runs that start on one delivery at the same moment (the File arrived script and the button script) cannot both get it. The claim becomes “processed” when the run and all its writes went through, and is released when the run fails, so the next run imports it; a claim of a run that died expires after its timeout plus ten minutes. system.file.mark_processed() marks a file without a claim (or adds a note), collected like a write.

Try it on a real delivery first. Try it out (and the assistant’s dry run) reads the real file and stores, moves and marks nothing — the result lists what it would have done.

Limits. system.file.write obeys the store’s file size and quota. system.file.read is bounded per run in bytes (512 MB unless the installation sets SCRIPT_FILE_READ_BUDGET_MB) and in files (1,000); the run’s own wall clock, memory and file-size limits apply on top. An importer usually needs more than the default five seconds: give it a timeout of up to 60 s.

A button on a board needs no widget of its own: a setpoint widget writes a number tag, a script with a Tag change trigger on that tag runs the import, and the script writes its status (“42 rows imported, 3 duplicates skipped” or the error) into a text tag that a KPI tile shows. Write the import once as a library and start it from both scripts — the button one and the File arrived one.

inputs or system.tag.read() — take inputs

Section titled “inputs or system.tag.read() — take inputs”

A bound input is the traceable route: TrakState then knows this script depends on that tag, and can show you.

# Bound: the name on the left comes from your input list.
parts = inputs["part_count"]

system.tag.read() is convenient and the weaker choice: nothing outside your text knows the script depends on that tag. Rename the path and the script breaks with nobody warned.

# Unbound: only when the path is not known until run time.
line = params["line"]
parts = system.tag.read("Plant." + line + ".PartCount")

system.tag.read() fails loudly when the value is not there — it does not return None. A silently missing value becomes a wrong number further down.

system.tag.write() sends nothing. It appends your request to a list the host works through afterwards — or does not, which is exactly what a dry run is: the same code, the same arithmetic, the writes left undone.

Two consequences worth knowing:

  • A system.tag.write() has not happened yet when the call returns. A system.tag.read() on the same path in the same execution still sees the old value.
  • The permission check stays on the host. A script cannot write anything its trigger would not be allowed to write.

system.push.send() works exactly like system.tag.write(): it sends nothing itself, it appends your request to a list the host works through afterwards — or does not, in a dry run, where the panel lists it as “would send” and nobody’s phone buzzes.

system.push.send("quality", "Press1 stopped", "The press has been down for 5 minutes.",
path="/alarms/press1", tag="press1-down")
  • role is a role name, not a person — everyone holding it gets the notification, on every device they registered (a browser tab and the TrakState app alike).
  • path is where tapping the notification should land, relative to your own instance.
  • tag groups related notifications, so a later one replaces an earlier one on screen instead of piling up; the dispatcher also uses it to avoid sending the same notification twice within a minute.
  • A run is limited to 10 system.push.send() calls; the rest are reported as an error on the run rather than sent.

A script writes a record the same way it writes a value: through the tag.

system.tag.write("Process.Records", {
"At": system.time.plant_now().isoformat(),
"Machine": "ERA-01",
"Area": 3.6,
})

That is how machine data becomes rows you can evaluate: a connector or another script puts the machine’s message on a tag, your script turns it into a row, and the record tag is the only door it goes through. Nothing writes to the database directly.

Two things to know, and both cost an afternoon if you meet them by surprise:

  • The row is attributed to your script, by its path — script:Decoders.ERA-01 — and to the person instead when they pressed Run. A record without an author is refused outright, because an unattributed row breaks the trail that capture is required to keep. You do not set this and you cannot: it comes from who or what actually ran.
  • Values must be plain: text, numbers, true/false. system.time.plant_now() gives you a datetime, and a datetime inside a written value cannot make the trip — write .isoformat(), as above. A value that cannot be carried fails the run, not the field.

The row also records how it came in: script for these, beside manual for something a person typed and assistant for something the assistant captured. A figure that looks wrong is much easier to explain when the row says where it came from.

system.time.plant_now() gives you plant time — as a datetime that knows its zone.

now = system.time.plant_now()
shift_start = now.replace(hour=6, minute=0, second=0, microsecond=0)

datetime.now() is a different thing: the time zone of the machine your script runs on. On a hosted instance that is reliably UTC. A script the schedule starts at “Monday 06:00” plant time would then compute with a different clock than its own trigger. As long as the two zones happen to agree, nothing shows — it shows at the shift handover at six in the morning.

That is why it is a named function and not a time zone on the process: an environment variable would have quietly redefined datetime.now() and changed every script that computes in UTC on purpose. For everything but the plant’s wall clock, datetime stays Python’s datetime.

system.ai.ask(prompt, data) asks the language model a question and returns the answer as text — the same model the assistant uses.

figures = system.tag.read("KPI.ShiftSummary")
report = system.ai.ask("Summarise this shift in three sentences and name anything unusual.", figures)
system.tag.write("Reports.Records", {"At": system.time.plant_now().isoformat(), "Text": report})

Your script does the arithmetic, not the model. Hand it what a query or your code worked out, in data. The model is instructed to use only those figures and to say when something is missing. A report whose figures a model made up convinces exactly the reader who does not check.

The answer comes in the interface language of the person the run belongs to — whoever pressed Run now or wrote the value that set it off, or, for a run nobody started (interval, schedule, events), whoever last saved the script; without either it is English. The language of your question does not decide it, because the person who writes the script is rarely the one who reads the report.

Three things worth knowing:

  • Script objects only — started by a trigger or by hand. A script tag is evaluated on every read, and a board that re-reads regularly would turn one tile into a model bill. There the call fails with a message saying so.
  • The wait for the model does not count against the time limit. One question waits at most 60 seconds; the time limit applies to your own code.
  • Every question costs. It counts against the tenant’s AI budget and shows up in consumption as script.ask. More than 40,000 characters of data are refused — aggregate in a query first.

In many scripting environments standard output is the result channel — the first print() you add to see what a value looks like corrupts the answer. Not here: result and output are separate. Your output appears in the run log, under the run.

print("good:", inputs["good"], "scrap:", inputs["scrap"])

print() stays in the run log. What should reach the diagnostics panel - a warning, an error the script caught itself - goes through system.log.info/warn/error (table above).

Python’s standard library — among others:

json math time datetime zoneinfo calendar decimal fractions statistics
random re string textwrap collections itertools functools operator
typing dataclasses enum copy uuid hashlib base64 binascii struct
csv io os sys pathlib bisect heapq abc

Plus your own libraries, by their path used as a module name (see Libraries).

An import that names neither one of your libraries nor a known standard module is reported in the Problems tab as a hint, not an error. TrakState does not know your plant’s Python installation, so it never claims a module does not exist — only that it was not found here.

You do not import system. It is there before your first line runs.

Time limit per run 5 seconds
Waiting for the model does not count against the time limit; at most 60 seconds per question
Memory 256 MB (on Linux; not enforceable on Windows)
Output volume capped — a loop full of print() cannot fill memory
Return value must be representable as JSON

A script that runs into the time limit ends with “the script was stopped after 5000 ms”. That is not a fault in your code; it is its running time.

Open: the time limit can be stored per script, but the interface currently offers no field for it. Until that is added, the 5 seconds apply.

An error is reported as its type, its message and the line number in your code — not in ours. Even when system.tag.read() is what failed, the message points at the line where you called it.

ZeroDivisionError: division by zero (line 4)