Skip to content

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.

FunctionReturnsNotes
tags.read(device, tagPath)string or undefinedundefined also when the device name doesn't exist.
tags.readQualified(device, tagPath){ value, quality, timestamp } or undefinedquality 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)nothingAlways the local device. Recorded with the tag's data type.
tags.writeBatch([{ tagPath, value }]) asyncnothingSeveral local writes.
tags.writeDevice(device, tagPath, value)nothingSends 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.write fires Tag Change triggers even when the value didn't change.
  • tags.writeDevice throws Device not found: X or Write 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 an ERROR: Failed to write … line is added to the log (only if the script is still running).

devices — look up devices ​

FunctionReturns
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 ​

FunctionReturns
http.get(url, headers?) asyncResponse body
http.post(url, body, headers?) asyncResponse body; body is sent as JSON
http.put(url, body, headers?) asyncResponse body
http.delete(url, headers?) asyncResponse 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 null when it's empty (such as a 204).
  • get, post, put and delete throw when the status isn't 2xx, with a message like HTTP 500 from POST https://…: <start of the body>. The error also has status and body properties.
  • http.request never throws because of the status. Check ok (true for 2xx) or status yourself. headers has 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 to http.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.

FunctionReturns
namedQueries.run(name, params?) asyncArray 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.

FunctionReturns (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:

  • where matches columns for equality, joined with AND. Keys that aren't columns are silently ignored — a typo returns all rows.
  • orderBy sorts ascending by a column, id or created_at. Without it (or with an unknown column) rows come newest first (id descending). There is no descending option; query newest-first by leaving orderBy out.
  • limit is capped at 1000. With no limit, every row is returned.
  • insert ignores keys that aren't columns. Values are converted to the column's type, exactly as in the table editor: true/false are stored as 1/0 in a boolean column, and a value that doesn't fit throws, for example Invalid value for "qty": Error: Expected integer, got: ten. A required column that is missing or null throws Column "X" is required; false, 0 and '' are values and are accepted. If no key matches a column: No valid columns provided.
  • update converts values the same way and also sets updated_at. No matching column: No valid changes provided.
  • A read-only table throws Table "X" is read-only on insert, update and delete.
  • Remote tables (tables linked from a remote database) return a Promise from query, insert, update and delete — await them. db.transaction only covers local tables.

alarms — active alarms ​

FunctionReturns / 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:

  • severity is 'info', 'warning' or 'error' ('critical' also works; 'error' is stored as critical). Anything else throws.
  • The alarm is written to alarm history as title: message with that severity, appears on the Alarms page and in alarms.getActive(), and every connected browser gets a toast titled title.
  • 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 notify again 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 ​

FunctionReturns
email.isConfigured()true when an email service is set up in Settings
email.send({ to, subject, text?, html? }) asyncnothing; 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 ​

FunctionReturns
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'. Pass undefined for stop to 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).

FunctionReturns
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) asyncnothing
workflows.setEnabled(target, enabled) asyncnothing
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');
  • params become workflow variables: any node field can use {{setpoint}}.
  • A disabled workflow doesn't run: run returns status failed with error Workflow "X" is disabled. Disabling also removes its triggers; a run already in flight finishes.
  • run, trigger and the enable functions throw Workflow not found: X. status and find return null instead.
  • Workflow → script → workflow chains stop at 5 levels deep.

ladder — start and stop ladder tasks ​

Takes a ladder task id or name.

FunctionReturns
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 ​

FunctionReturns
messages.send(name, payload?, { requireListener?, peer?, timeoutMs? })Number of scripts and workflows that received it
messages.sendRequest(name, payload?, { peer?, timeoutMs? }) asyncThe 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, with context.messageName (in a workflow: {{lot}}, {{messageName}}).
  • send doesn'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 returns 0 if 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.
  • sendRequest gets one answer. When a script listens, the first listening script runs, and sendRequest waits for it and resolves to the value it returns (undefined if it returns nothing). When no script listens, the first listening workflow answers with the value of the first Reply node its run reaches (undefined if it finishes without one). If the one answering fails, sendRequest rejects with Message "X" handler failed: <error>. With nobody listening it resolves to undefined without 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.fromPeer is { 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.fromPeer is 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 "…".
  • send stays 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.
  • sendRequest resolves 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.
  • timeoutMs is 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 sendRequest reply. Otherwise it throws, for example messages.send: the payload is larger than 64 KB.
  • Mesh networking must be on: otherwise any peer throws … 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.), sendRequest rejects with that message. send throws it only with { requireListener: true }; without it, send returns 0 and 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 with tags.read and 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.

FunctionReturns / 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 ​

FunctionReturns
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 ​

FunctionDoes
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 _STATUS tag 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').

FunctionDoes
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.

See also ​

Scada.Ink Help Center