Client Scripts
Client scripts are JavaScript that runs in the operator's browser. They are for screen logic: validating input, computing display values, coordinating components, deciding what to show.
They are used as event actions and as project-level script resources.
The system API in the browser
The same surface as everywhere else, with every call returning a promise:
const r = await system.tag.read("Line1/Filler/Motor1/Speed");
if (r.quality !== "Good") {
context.ui.notify({ message: "Speed unavailable", severity: "warning" });
return;
}
await system.tag.write("Line1/Filler/Motor1/SpeedSetpoint", Math.round(r.value * 0.9));
Screen context
Client scripts additionally get context, the screen-local surface. What follows is the tour; the
per-member detail, the two context shapes and worked examples are in the
Context API reference.
The screen
context.self | This component — .id, .props, .updateProps({...}). context.prop is shorthand for self.props. |
context.root · context.nodes | The view's root node, and every node in the view keyed by id. |
context.find(name) · context.findAll(name) | Find a node anywhere in the view by name; findAll returns an array. |
context.view | .id, .name, and .switchView(viewId). |
context.params · context.setParam(key, value) | The view's params, and a runtime setter. |
context.project · context.custom | Project manifest data, and the view's custom props. |
context.refreshBinding(target?) | Re-evaluate bindings now — 'self' (default), 'view', or a component id. Queries refetch too. |
Live data
context.tag["Plant/Line1/Temp"] | The runtime tag object — .value, .quality, and the full static definition joined on as .meta (.meta.writable, .data_type, .address, .eng_low, .enable_history). |
context.client | Client variables — tab-local read/write state shared across every view and client script in this runtime. context.client.selectedPlant = "Plant2", read back as {{client.selectedPlant}}. Ephemeral, and not a security boundary. |
context.vars | Gateway variables, read-only — a polled mirror of the server-side set. To write one, use system.vars.set(...), which is permission-gated. |
The operator's session
context.session describes who is looking at the screen, on what:
session.auth | .isAuthenticated, .username, .roles, and .hasRole(role). |
session.device | .type (desktop / tablet / mobile), .os, .browser. |
session.gateway | .address, .locale, .timezone. |
session.theme | .mode (light / dark), .themeColor, and whether the operator may change either. |
Writing to session.gateway.timezone or session.theme overrides the value for the rest of that
operator's session — which is how a screen offers a timezone or a theme switch.
session.auth is for presentation, not permissionReading hasRole("Supervisor") to decide what to show is fine. Reading it to decide what to
allow is not — see below.
Talking to the rest of the screen
context.message.send(type, payload?, opts?) | Fire every component with a matching On message handler. opts.scope is session (the whole tab, the default), view, page, or component with opts.target. |
context.message.onMessage(type, cb) | Subscribe imperatively; returns an unsubscribe. It is owned by this component and de-duplicated per type, so it cannot leak — calling the returned function is only for cancelling early. |
context.message is local to the browser. To push from the server down to browsers, use
system.message.send — a different direction and a different scope.
Panels, notifications and popovers
context.ui controls the shell around the view. It lives on the context rather than on system.*
because all of it is local to the browser.
ui.openPanel(idOrName, opts?) · ui.closePanel() · ui.togglePanel() | Omit the name and it acts on the panel the script is running in. |
ui.toggleDock('left' | 'right' | 'header' | 'footer') | Collapse or expand a docked region. |
ui.notify({ message, title?, severity? }) | A simple toast. |
ui.notify(slotName, payload?, opts?) | Render an author-defined notification View instead. Returns its instance id, or undefined when no such slot is configured. |
ui.dismissNotification(id?) | Omit the id to dismiss the notification the script is running inside. |
ui.openPopover(view, opts?) · ui.closePopover(id?) · ui.togglePopover(view, opts?) | An anchored popover hosting a View, referenced by id, slug, name or folder path. |
A popover has to be positioned, and only a DOM event knows where. A script running in a widget event gets the firing element's box; anywhere else it falls back to the last click, then the centre of the viewport. That is deliberate — a popover raised by a tag change has no on-screen cause to point at.
Inside an event chain
An event action's script gets a few more things, because an event is a sequence of actions:
context.last · context.ok · context.error | The previous script or set-tag action's value, whether it succeeded, and its failure. Notify, navigate, panel and dock actions do not count. |
context.results · context.outcomes | Every prior action's value, and every prior action's { type, ok, value, error } — successes and failures — in order. |
context.signal | An AbortSignal. Pass it to fetch({ signal }); it aborts when the widget unmounts or the action times out. |
context.store | The designer store, for advanced use. |
Patterns
Validate before writing
const sp = Number(context.find("Input_Setpoint").props.value);
if (!Number.isFinite(sp) || sp < 0 || sp > 3000) {
context.ui.notify({ message: "Setpoint must be between 0 and 3000 RPM", severity: "error" });
return;
}
await system.tag.write("Line1/Filler/SpeedSP", sp);
context.ui.notify({ message: "Setpoint applied", severity: "success" });
Confirm consequential actions
Confirmation is not something a script asks for — it is an option on the action. Tick Confirm on a Set tag or Set variable action and give it a message, and the operator is asked before the write happens.
That is deliberate: a confirmation a script raises could be skipped by a script, and the point of one is that it cannot be.
Coordinate components
context.message.send("filterChanged", { line: context.find("Dropdown_Line").props.value });
Every component with an On message handler for filterChanged fires. Narrow the reach with
{ scope: "view" } when the message means something only on this screen.
Hand off to the gateway
const batchId = context.find("Table_Batches").props.selectedRow?.id;
const res = await system.script.runOnGateway(
"batches.releaseBatch",
{ batchId },
{ idempotencyKey: `release-${batchId}` },
);
if (!res.ok) {
context.ui.notify({ message: res.error, severity: "error" });
return;
}
context.ui.notify({ message: "Batch released", severity: "success" });
Client scripts are not a security boundary
Everything a client script does is subject to server-side authorization:
- Tag writes are gated by the tag's
writableflag and write level. - Data access is gated by the project's access policy and the operator's runtime roles.
- Capability scopes gate the
systemAPI.
Never put an authorization decision in a client script. "Only supervisors may release a batch" belongs in a Gateway script, where the operator cannot skip it. Hiding the button is good usability; it is not the control.
Async and errors
Every system call returns a promise. Forgetting await is the most common client-script bug — the
script continues with a promise object instead of a value, and the failure is silent.
// ❌ writes "[object Promise]"
const r = system.tag.read("Line1/Tank1/Level");
// ✅
const r = await system.tag.read("Line1/Tank1/Level");
Handle failures explicitly:
try {
await system.tag.write("Line1/Filler/SpeedSP", sp);
context.ui.notify({ message: "Applied", severity: "success" });
} catch (e) {
context.ui.notify({ message: `Write rejected: ${e.message}`, severity: "error" });
}
A rejected write is normal — the tag may be read-only, or the operator's level may be below its write level. Say so, rather than leaving the screen silently unchanged.
Performance
- Client scripts run on the operator's panel, which is usually far weaker than your laptop.
- Batch tag reads (
readAll) rather than looping. - Do not poll in a script. Bind, and let the subscription push.
- Move heavy computation to the gateway; the browser's job is rendering.
Debugging
- The browser developer console shows script errors and
console.logoutput. context.ui.notifyis useful for tracing a flow an operator will actually see.- If a write silently does nothing, check the tag's
writableflag and write level first — that is the answer far more often than a script bug.