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.
One word everything hangs under
Section titled “One word everything hangs under”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, writeThe former names
read_tagandwrite_tagare gone, with nothing in their place and no transition period. A script that still calls them fails with aNameError. Replace them withsystem.tag.read()andsystem.tag.write().
Two shapes
Section titled “Two shapes”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.
Hooked
Section titled “Hooked”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 valueA 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_writeand notget/set, becausesetis a Python builtin. Shadowing it breaks a laterset([1, 2])in the same script.
What is available
Section titled “What is available”| 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.
Writes are collected, not performed
Section titled “Writes are collected, not performed”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. Asystem.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.
Pushes are collected, not sent
Section titled “Pushes are collected, not sent”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")roleis 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).pathis where tapping the notification should land, relative to your own instance.taggroups 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.
Writing a record
Section titled “Writing a record”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 adatetime, 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.
The clock the plant runs by
Section titled “The clock the plant runs by”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.
Asking the AI
Section titled “Asking the AI”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.
print() is yours
Section titled “print() is yours”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).
What you may import
Section titled “What you may import”Python’s standard library — among others:
json math time datetime zoneinfo calendar decimal fractions statisticsrandom re string textwrap collections itertools functools operatortyping dataclasses enum copy uuid hashlib base64 binascii structcsv io os sys pathlib bisect heapq abcPlus 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.
Limits
Section titled “Limits”| 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.
When something goes wrong
Section titled “When something goes wrong”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)