Appearance
Scripting API Reference
Last updated
Everything a script can call. All objects are globals — no import and no prefix. Functions marked async return a Promise: await them, or the script can finish before they do. Everything else returns its value directly.
tags — read and write tag values
Reads come from the live value cache: they return immediately, never poll the device, and return undefined for a tag with no value yet. Values are strings ('1200', 'true') — convert with Number() before doing arithmetic or comparing.
Wherever a function takes device, pass the numeric device id or the device's name (matched ignoring case).
A paired appliance's shared tags are tags of a device named after it, so these functions read and write them too, for example tags.read('Line 2', 'Local Tags/Speed'). See Paired Appliances (Mesh) for which tags are shared and writable.
| Function | Returns | Notes |
|---|---|---|
tags.read(device, tagPath) | string or undefined | undefined also when the device name doesn't exist. |
tags.readQualified(device, tagPath) | { value, quality, timestamp } or undefined | quality is 'unknown' if not reported. |
tags.readAll(device) | { [tagPath]: string } | Empty object for an unknown device. |
tags.readAllQualified(device) | { [tagPath]: { value, quality, timestamp } } | |
tags.readBatch(device, [paths]) async | { [tagPath]: string or undefined } | Same as several read calls. |
tags.write(tagPath, value) | nothing | Always the local device. Recorded with the tag's data type. |
tags.writeBatch([{ tagPath, value }]) async | nothing | Several local writes. |
tags.writeDevice(device, tagPath, value) | nothing | Sends a write to a real device through its driver. |
javascript
const speed = Number(tags.read('CNC-001', 'spindle_speed')); // by name
const same = tags.read(3, 'spindle_speed'); // by id
const q = tags.readQualified(3, 'spindle_speed');
// { value: '1200', quality: 'good', timestamp: '2026-09-15T12:00:00.000Z' }
tags.write('Line1/alert_status', 'active'); // local tag
tags.writeDevice('CNC-001', 'target_speed', 1000); // device tag
const some = await tags.readBatch(3, ['spindle_speed', 'feed_rate']);
await tags.writeBatch([
{ tagPath: 'setpoint_a', value: 10 },
{ tagPath: 'setpoint_b', value: 20 },
]);Behavior of writes:
- If no local tag has that path, the value still lives in memory, updates live screens and fires Tag Change triggers, but it isn't saved and is gone after a restart. The run's Log gets a WARNING: no local tag … line the first time each such path is written. Create the tag on the Local Tags page first if it should persist.
- The value is recorded with the tag's own data type (bool, int, float or string).
tags.writefires Tag Change triggers even when the value didn't change.tags.writeDevicethrowsDevice not found: XorWrite access is not enabled for device X. Enable it in device settings.before sending. The write itself is sent without waiting: if the driver then fails, the run still succeeds and anERROR: Failed to write …line is added to the log (only if the script is still running).
devices — look up devices
| Function | Returns |
|---|---|
devices.find(name) | Device id (number), or null. Case-insensitive. |
devices.localId() | Id of the built-in local device, or null. |
devices.list() | [{ id, name, driver, enabled }] sorted by name. |
log and console — write to the run log
javascript
log('Processing started', { part: 'ABC-123' }); // → Processing started {"part":"ABC-123"}
console.log('info line');
console.warn('coolant low'); // → WARN: coolant low
console.error('bad reading'); // → ERROR: bad reading (the run does NOT fail)Arguments are joined with spaces; objects and arrays are JSON-stringified. Lines appear in the Log tab after the run finishes, not live.
Tag writes are logged for you. Every tags.write, tags.writeBatch and tags.writeDevice adds a line with the device, the tag, the new value and the value before the write:
TAG WRITE: Local Tags › partsThisShift = 43 (was 42)At most 200 writes are logged per run; after that one line says the rest were not logged. A writeDevice line records the request — if the device then rejects it, an ERROR: Failed to write … line follows.
http — call web services
| Function | Returns |
|---|---|
http.get(url, headers?) async | Response body |
http.post(url, body, headers?) async | Response body; body is sent as JSON |
http.put(url, body, headers?) async | Response body |
http.delete(url, headers?) async | Response body |
http.request(method, url, body?, headers?) async | { status, ok, headers, body } |
- Each request times out after 10 seconds. The request body and the response are size-capped.
- The response body is parsed JSON when the reply is JSON, the text when it isn't, and
nullwhen it's empty (such as a 204). get,post,putanddeletethrow when the status isn't 2xx, with a message likeHTTP 500 from POST https://…: <start of the body>. The error also hasstatusandbodyproperties.http.requestnever throws because of the status. Checkok(true for 2xx) orstatusyourself.headershas lowercase names.
Note: Scripts written before this behavior may have checked the body of an error reply for an error field. Those calls now throw instead. Wrap them in
try/catch, or switch tohttp.request.
javascript
const status = await http.get('https://mes.example.com/api/status', { Authorization: 'Bearer abc123' });
const created = await http.post('https://mes.example.com/api/events', { type: 'cycle_done', machine: 'CNC-001' });
try {
await http.post('https://mes.example.com/api/events', { type: 'scrap' });
} catch (err) {
log('MES refused the event:', err.status, err.body);
}
const r = await http.request('GET', 'https://mes.example.com/api/orders/42');
if (r.status === 404) log('No such order');Network access and the loopback rule
The appliance makes the request for the script, and three switches decide whether it may:
- Internet (per script) — public addresses.
- Plant network (per script) — private ranges (10/8, 172.16/12, 192.168/16) and link-local.
- Allow scripts network access (appliance-wide, in Settings) — the master switch. While it is off, neither per-script switch has any effect.
Only an administrator can change any of these, on the script editor or in Settings, and every change is recorded in the audit log. For JavaScript they all start on, so existing scripts keep working. When a switch blocks a request the call throws a clear error naming the switch to turn on.
Loopback is always refused, except the appliance's own API port. So a call to http://localhost:3001/api/... works, but InfluxDB, the drivers' ports and any other local service are refused — reach those through the Scada.Ink API (tags, Data Tables, named queries) instead. The appliance also refuses a script's own calls to its appliance, licensing, update and security routes even on the API port.
The appliance resolves the host name once and connects to that exact address, and re-checks every redirect, so a server can't redirect a script to a blocked address.
To call the Scada.Ink REST API from a script, use an API token (see REST API) in the Authorization header and the appliance's own address, e.g. http://localhost:3001/api/....
namedQueries — run SQL by name
The way for a script to run SQL, including joins, aggregates and remote databases. The SQL is fixed on the Named Queries page; the script only supplies values. See Named Queries.
| Function | Returns |
|---|---|
namedQueries.run(name, params?) async | Array of row objects. For a write query, an empty array. |
namedQueries.runFull(name, params?) async | { columns, rows, rowCount, truncated, cached, durationMs, rowsAffected } (rowsAffected only for writes) |
namedQueries.list() | [{ name, description, params: [names] }] for enabled queries |
javascript
const rows = await namedQueries.run('downtime_by_reason', { line: 2, start: '2026-09-14T06:00:00Z' });
const res = await namedQueries.runFull('ack_batch', { batch: 'B-1042' });
log('rows changed:', res.rowsAffected);Errors: Named query not found: X (name is case-sensitive), Query "X" is disabled, Missing required parameter "p", Unknown parameter "p", Parameter "p" must be a number. A write query also needs the script to have been last saved by someone whose role the query allows (admins always).
db — Data Tables
Reads and writes the tables on the Data Tables page, addressed by their display name (exactly as shown). See Data Tables.
| Function | Returns (local table) |
|---|---|
db.query(table, { where, orderBy, limit, offset }) | Array of rows |
db.insert(table, row) | { id } |
db.update(table, rowId, changes) | { changes } (rows updated) |
db.delete(table, rowId) | { changes } |
db.tables() | Array of table names (local and connected remote tables) |
db.transaction(fn) | nothing; runs fn atomically |
javascript
const recent = db.query('production_log', { where: { machine: 'CNC-001' }, orderBy: 'created_at', limit: 10 });
const { id } = db.insert('production_log', { machine: 'CNC-001', part_number: 'ABC-123', cycle_time: 45.2 });
db.update('production_log', id, { cycle_time: 44.8 });
db.delete('production_log', id);
db.transaction(() => {
const batch = db.insert('batches', { name: 'B-42' });
db.insert('batch_items', { batch_id: batch.id, sku: 'X1' });
db.insert('batch_items', { batch_id: batch.id, sku: 'X2' });
});Details:
wherematches columns for equality, joined with AND. Keys that aren't columns are silently ignored — a typo returns all rows.orderBysorts ascending by a column,idorcreated_at. Without it (or with an unknown column) rows come newest first (iddescending). There is no descending option; query newest-first by leavingorderByout.limitis capped at 1000. With nolimit, every row is returned.insertignores keys that aren't columns. Values are converted to the column's type, exactly as in the table editor:true/falseare stored as 1/0 in a boolean column, and a value that doesn't fit throws, for exampleInvalid value for "qty": Error: Expected integer, got: ten. A required column that is missing ornullthrowsColumn "X" is required;false,0and''are values and are accepted. If no key matches a column:No valid columns provided.updateconverts values the same way and also setsupdated_at. No matching column:No valid changes provided.- A read-only table throws
Table "X" is read-onlyon insert, update and delete. - Remote tables (tables linked from a remote database) return a Promise from
query,insert,updateanddelete—awaitthem.db.transactiononly covers local tables.
alarms — active alarms
| Function | Returns / does |
|---|---|
alarms.getActive() | Array of active, unshelved alarms, newest first. Each row has id, alarm_rule_id, rule_name, device_id, tag_path, value, message, severity, fired_at, acknowledged_at, acknowledged_by, cleared_at, shelved. |
alarms.acknowledge(id) | Acknowledges it (no user recorded). No effect if already acknowledged. |
alarms.clear(id) | Clears it. Returns true if an alarm was cleared. |
alarms.notify(severity, title, message) | Raises an alarm and returns its id. See below. |
alarms.notify raises a real alarm with no alarm rule behind it, like the alarm for a device going offline:
severityis'info','warning'or'error'('critical'also works;'error'is stored as critical). Anything else throws.- The alarm is written to alarm history as
title: messagewith that severity, appears on the Alarms page and inalarms.getActive(), and every connected browser gets a toast titledtitle. - It stays active until an operator acknowledges it or a script calls
alarms.clear(id). Acknowledging it also clears it, because nothing else would. - While it is active, calling
notifyagain with the same severity, title and message returns the same id instead of adding another alarm, so a script on a short interval doesn't flood the list. - It can be shelved like any other alarm. It does not run pipelines or send email, because pipelines are attached to alarm rules. To page someone, also call
email.send, or alarm on a tag with a rule and a pipeline (see Alarms).
javascript
const id = alarms.notify('warning', 'Coolant low', 'Tank at ' + level + '%');
// later, once the level recovers:
alarms.clear(id);email — send email
| Function | Returns |
|---|---|
email.isConfigured() | true when an email service is set up in Settings |
email.send({ to, subject, text?, html? }) async | nothing; throws if sending fails |
to is one address or an array. to and subject are required (email.send requires { to, subject }). There is no SMS function in scripts.
javascript
if (email.isConfigured()) {
await email.send({
to: ['maintenance@plant.local', 'supervisor@plant.local'],
subject: 'CNC-001 coolant high',
text: 'Coolant temperature is 64 °C (limit 60 °C).',
});
}history — the historian
| Function | Returns |
|---|---|
history.query(device, tagPath, start, stop?) async | [{ timestamp, value }], oldest first. Up to 1000 raw points; a range with more returns about 1000 per-window means spanning all of it (text tags: the newest 1000) |
history.queryAggregated(device, tagPath, start, stop, window, fn) async | [{ timestamp, value }], at most 2000 points; a window too small for the range is widened |
start/stop: ISO timestamps or relative times such as'-1h','-24h','-7d'. Passundefinedforstopto mean now.window: a duration such as'1m','15m','1h'.fn:'mean','max','min','sum','count','last','first'('avg'means'mean').- The tag must have history enabled. See Historian.
javascript
const lastHour = await history.query('CNC-001', 'spindle_speed', '-1h');
const hourlyMax = await history.queryAggregated('CNC-001', 'coolant_temp', '-24h', undefined, '1h', 'max');workflows — run and control workflows
Every function takes a workflow id or name (name ignores case).
| Function | Returns |
|---|---|
workflows.run(target, params?) async | { status, error, executionId } when the workflow finishes |
workflows.trigger(target, params?) | nothing; starts it without waiting |
workflows.enable(target) / disable(target) async | nothing |
workflows.setEnabled(target, enabled) async | nothing |
workflows.status(target) | { id, name, enabled, lastStatus, lastRunAt } or null |
workflows.find(name) | id or null |
workflows.list() | [{ id, name, enabled }] |
javascript
const r = await workflows.run('Purge Cycle', { setpoint: 65, operator: 'A. Smith' });
if (r.status !== 'completed') log('purge failed:', r.error);
workflows.trigger('Nightly Report', { shift: 'C' });
await workflows.disable('Auto Purge');paramsbecome workflow variables: any node field can use{{setpoint}}.- A disabled workflow doesn't run:
runreturnsstatusfailedwitherrorWorkflow "X" is disabled. Disabling also removes its triggers; a run already in flight finishes. run,triggerand the enable functions throwWorkflow not found: X.statusandfindreturnnullinstead.- Workflow → script → workflow chains stop at 5 levels deep.
ladder — start and stop ladder tasks
Takes a ladder task id or name.
| Function | Returns |
|---|---|
ladder.start(target) / ladder.stop(target) | nothing; no-op if already in that state; throws Ladder task not found: X |
ladder.isRunning(target) | true/false (false for an unknown task) |
ladder.status(target) | Scan status (running, scan times, fault message, rung states) or null |
ladder.find(name) | id or null |
ladder.list() | [{ id, name, enabled, running }] |
messages — messages between scripts and workflows
| Function | Returns |
|---|---|
messages.send(name, payload?, { requireListener?, peer?, timeoutMs? }) | Number of scripts and workflows that received it |
messages.sendRequest(name, payload?, { peer?, timeoutMs? }) async | The answer: the receiving script's return value, or a workflow's Reply |
- Scripts with the Message Handler trigger and workflows with the Message Received trigger receive it, when they are enabled and have that exact Message Name. A workflow sends with the Send Message node; see Communication Nodes.
- The payload's fields are copied onto the receiver's
context, withcontext.messageName(in a workflow:{{lot}},{{messageName}}). senddoesn't wait for the receivers to finish. It returns how many it reached, scripts and workflows together; a workflow that is still running skips the message and isn't counted. It returns0if nobody is listening. Pass{ requireListener: true }to throw instead:Message "X" not delivered: no enabled script or workflow listens for it.,… recipient script "Y" is disabled.or… recipient workflow "Y" is disabled.sendRequestgets one answer. When a script listens, the first listening script runs, andsendRequestwaits for it and resolves to the value itreturns (undefinedif it returns nothing). When no script listens, the first listening workflow answers with the value of the first Reply node its run reaches (undefinedif it finishes without one). If the one answering fails,sendRequestrejects withMessage "X" handler failed: <error>. With nobody listening it resolves toundefinedwithout an error.- Loops are stopped: a script or workflow started by a message can send a message, which starts another, and so on, five messages deep. The sixth throws
Message "X" not sent: it would be message 6 in a chain of messages that start each other (limit 5)…. The count carries across paired appliances.
javascript
const n = messages.send('recalc_totals', { line: 'A' }, { requireListener: true });
// in the "recalc_totals" handler script: context.line === 'A'
const total = await messages.sendRequest('line_total', { line: 'A' });
// in the "line_total" handler script: return db.query('production_log', { where: { line: context.line } }).length;Messages to a paired appliance (peer)
Add peer to send to Message Handler scripts and Message Received workflows on another appliance paired on the Mesh page. peer is that appliance's name as listed under Paired appliances, or its node ID. Without peer, nothing changes.
- On the receiving appliance the handler runs exactly as for a local message, and
context.fromPeeris{ id, name, deviceId, sender }: the sender's node ID, its name, the id of the device that mirrors it there, and what sent it (script "Batch end",workflow "Pack-out").context.fromPeeris only ever set by a paired appliance, never by a local payload. The handler's run log starts with Message "X" from paired appliance "Line 1" (…), sent by script "…". sendstays synchronous: it waits for the other appliance to say how many of its scripts and workflows the message reached (not for them to run) and returns that number.sendRequestresolves to the receiving script's return value (or workflow's Reply). With nobody listening there it rejects (unlike a local request):Message "X" not delivered on Line 2: no enabled script or workflow listens for it.timeoutMsis how long to wait for the other appliance: 10000 (10 s) by default, at most 60000. The script's own 30-second limit still applies.- The payload must be an object (or left out) that converts to JSON, up to 64 KB; so must a
sendRequestreply. Otherwise it throws, for examplemessages.send: the payload is larger than 64 KB. - Mesh networking must be on: otherwise any
peerthrows… sending to a paired appliance needs mesh networking, which is not enabled on this appliance. An admin turns it on in Settings → Feature Toggles. - Nothing is queued. When the appliance is unknown (
No paired appliance "X"…), no longer paired ("X" is no longer paired…), not connected (Paired appliance "X" is not connected.) or does not answer in time ("X" did not answer within 10 s.),sendRequestrejects with that message.sendthrows it only with{ requireListener: true }; without it,sendreturns0and the run log gets a WARN line with the reason. - Every cross-appliance call adds a line to the sender's run log, even when it fails: MESSAGE → paired appliance "Line 2": "X" delivered to 1 recipient(s) or REQUEST → paired appliance "Line 2": "X" answered in 35 ms.
javascript
// On Line 1: tell Line 2 a batch is done, and make sure something heard it
messages.send('batch/done', { lot: 42 }, { peer: 'Line 2', requireListener: true });
// On Line 1: ask Line 2 for its count and wait up to 5 s
const count = await messages.sendRequest('count/get', {}, { peer: 'Line 2', timeoutMs: 5000 });
// On Line 2, a Message Handler script for "count/get":
log('asked by ' + context.fromPeer.name);
return { good: Number(tags.read('Local Tags', 'GoodCount')) };To read or write a paired appliance's tags instead, use its device like any other: see Paired Appliances (Mesh).
expression — evaluate an expression
expression.eval(expr) substitutes tag references written as {DeviceName:tagPath} with their current values, then evaluates the result as a simple expression. Available: arithmetic, comparisons, &&/||, if(cond, a, b), switch(value, match1, …, result1, …), abs, round, floor, ceil, min, max, sqrt, pow, toInt, toFloat, toString, now(), PI, E.
javascript
const ok = expression.eval('{CNC-001:spindle_speed} > 1000 && {CNC-001:coolant_temp} < 60');- Use the device name in the reference. A number such as
{3:spindle_speed}is looked up as a device named "3". - A missing tag or device becomes
0. - Text values are inserted as-is, so
{CNC-001:mode} == 'AUTO'doesn't work; if evaluation fails, the substituted string is returned instead of an error. For anything beyond simple numeric checks, read the tags withtags.readand write plain JavaScript.
auth — verify a password
auth.login(email, password) checks the credentials like the sign-in page. It returns { user, tokens: { token, refreshToken } } on success and null on failure (logging auth.login: failed for …). Any script can call it. It also records a sign-in for that user.
users — manage accounts (admin-saved scripts only)
Every users function throws users.<name> is restricted: this script must be saved by an administrator to use it. unless the script's code was last saved by an administrator.
| Function | Returns / does |
|---|---|
users.list() | [{ id, email, firstName, lastName, role, active }] |
users.add({ email, password, firstName?, lastName?, role? }) | New id. role is 'admin', 'editor', 'operator' or 'viewer'; anything else becomes 'viewer'. Throws if the email exists. |
users.update(id, { email?, firstName?, lastName?, role? }) | Refuses to demote the last admin. |
users.setPassword(id, newPassword) | Sets a new password. |
users.disable(id) | Blocks sign-in and logs the user out of every browser; keeps the account. Refuses to disable the last active admin. |
users.enable(id) | Allows sign-in again. |
users.remove(id) | Deletes the account. Refuses to delete the last admin. Prefer disable. |
users.logout(idOrEmail) | Logs that user out of every browser. |
users.logoutAll() | Logs everyone out. |
system
| Function | Returns |
|---|---|
system.backup() (admin-saved only) | { filename, path, sizeBytes, createdAt } — a snapshot of the configuration database, written to a backups folder next to the database file |
system.info() | { version, platform, uptimeSeconds, nodeVersion, dbPath }. version may read 'unknown' when the service isn't started through npm. |
sleep
await sleep(ms) pauses the run. Capped at 10 000 ms per call. Without await it does nothing useful.
Timers
| Function | Does |
|---|---|
setTimeout(fn, ms, ...args) | Calls fn(...args) once, ms later. Returns an id |
setInterval(fn, ms, ...args) | Calls fn(...args) every ms (at least 100). Returns an id |
clearTimeout(id) / clearInterval(id) | Cancels it. An unknown id does nothing |
javascript
tags.write('Line1/Horn', true);
setTimeout(() => tags.write('Line1/Horn', false), 5000); // horn off after 5 s- Timers belong to the script, not the run: the run finishes and is recorded at once, and its timers fire afterward. What a callback logs is added to the log of the run that started the timer (the newest 1,000 lines are kept).
- A script may have 20 timers pending at once; one more throws already has 20 timers pending. Each run of an Interval or Tag Change script adds its own timers, so clear what you start.
- All of a script's timers are cancelled when it is saved with changed code or restored to an older version, disabled, deleted, has its trigger changed, or when the service stops. They do not survive a restart.
- A callback may run 5 s of continuous code. A callback that throws marks the run that started the timer failed, with the error and its stack in that run's log, and raises the same Script Failed alert and
_STATUStag as any failure. - Timers are not available in a library's Run Test.
workOrders
Work orders, through the same rules as the Work Orders page. Needs the Work Orders feature, and runs with the authority of the script's author — the person who last saved its code, who must be an administrator, editor or operator with access to Work Orders. A work order is named by its id or its number ('WO-1042').
| Function | Does |
|---|---|
workOrders.list({ status, deviceId, limit }) | Work orders, highest priority then newest first. status is one status or a list (pending, active, complete, cancelled); limit defaults to 100, at most 500 |
workOrders.get(idOrNumber) | One work order, or null |
workOrders.active(deviceId) | The active work order on a device, or null |
await workOrders.start(idOrNumber) | Activates a pending work order. Downloads its recipe when the work order is set to; resolves to { recipeDownload } |
workOrders.complete(idOrNumber) | Completes an active work order |
workOrders.addCounts(idOrNumber, good, reject) | Adds to the counts of an active work order; returns { completedQuantity, rejectQuantity } |
workOrders.setCounts(idOrNumber, { completedQuantity, rejectQuantity }) | Sets the counts outright (to correct a miscount); a count left out is kept |
Each work order has id, number, partNumber, partName, deviceId, deviceName, recipeId, recipeName, targetQuantity, completedQuantity, rejectQuantity, status, priority, notes, startedAt and completedAt. Anything the page would refuse — starting a work order that isn't pending, a second active work order on the same device — throws with the same message. Start, complete and setCounts are written to the audit log as the author, naming the script; addCounts is not (like the count buttons).
javascript
// Count a good part on whatever work order is running on device 3
const wo = workOrders.active(3);
if (wo) {
const totals = workOrders.addCounts(wo.id, 1);
if (totals.completedQuantity >= wo.targetQuantity) workOrders.complete(wo.id);
}context
Information about this run: context.trigger, context.timestamp, and trigger-specific fields such as context.value or context.params. The full table is in Script Triggers & Context.