Skip to main content
Version: 1.0.8

System API Reference

system is the server-side surface: everything a script does that leaves the process it runs in — reading a tag off a PLC, querying a database, writing a file, sending a message to a pipeline or a screen. It is a global; there is nothing to import.

Its counterpart is context, which is everything local to the browser — the view, its components, the operator's session, panels and toasts. The split is the rule to remember: system.* crosses the wire and is permission-gated, context.* does not and is not.

Where system exists​

ContextLanguagesystem isNotes
Python node in a pipelinePythonSynchronousPlus the pipeline globals listed below
Gateway scriptPythonSynchronousWhere transactional and long-running work belongs
Client script — event action or project libraryJavaScriptPromise-returningdb.transaction is not available here; see Transactions
Binding transformJavaScriptPromise-returningKeep these tiny — they run on every value change

The method names are identical camelCase everywhere, so a script reads the same in both languages. Three things genuinely differ, and each is called out where it applies:

PythonJavaScript
CallsReturn a valueReturn a promise — await them
Field accessr["value"]r.value
system.script.runOnGatewayOptions are keyword arguments; refused inside a Gateway entry point 1.0.8An options object
system.db.transactionKeyword arguments, used with withAn options object, with or without a callback
system.filesReturns and accepts bytesReturns and accepts base64 strings
await is not optional in JavaScript

Forgetting it is the single most common client-script bug. The script continues with a promise object instead of a value, no error is raised, and the screen silently shows [object Promise].

How a call behaves​

Errors raise. A failed call raises an exception in Python and rejects the promise in JavaScript. A refusal — no permission, no such connection, a tag that is not writable — is an error like any other, and it carries a message worth showing to whoever pressed the button.

Every call has a transport budget. A system.* call that gets no reply within its budget fails rather than hanging; the default is 15 seconds, and the calls that can legitimately take longer (files.read, script.runOnGateway) set their own.

Every call is capability-gated at the server. See Scopes at the foot of this page for the mapping, and the sandbox for what a scope is.


system.tag​

Real-time tag access — the live value the runtime holds, not a historical one.

MethodSignatureReturnsScope
readread(pathOrPaths)One TagResult, or a list when given a listtag:read
readAllreadAll(paths)TagResult[]tag:read
writewrite(path, value)truetag:write
writeAllwriteAll(paths, values)true — paths[i] is set to values[i]tag:write

A path is the namespace path shown in the tag browser: Line1/Filler/Motor1/Speed.

TagResult​

FieldMeaning
valueThe current value
qualityGood · Bad · Uncertain · NotFound
source_timestampWhen the device sampled it
server_timestampWhen the server processed it
raw_valueThe value before scaling
status_codeThe protocol's own status code
tag_id · name · tag_pathIdentity
data_type · unitType and engineering unit
binding_type · connection_nameWhere it came from
array_dimensionsPresent for array tags
metaThe tag's full static definition

meta carries the configuration a screen or a script often needs alongside the value: data_type, unit, description, writable, enabled, source, address, protocol_type, connection_id, raw_low / raw_high, eng_low / eng_high, invert_scaling, clamp_scaled, quality_default, deadband, stale_after_ms, enable_history, store_interval_ms, storage_connection_id, format_string, created_at, updated_at.

Reading​

Python — one tag
r = system.tag.read("Line1/Filler/Motor1/Speed")
if r["quality"] == "Good":
log(f"{r['value']} {r['meta']['unit']}")
Python — many tags in one round trip
paths = [
"Line1/Filler/Motor1/Speed",
"Line1/Filler/Motor1/Current",
"Line1/Filler/Motor1/Torque",
]
readings = {r["tag_path"]: r for r in system.tag.readAll(paths)}
speed = readings["Line1/Filler/Motor1/Speed"]["value"]
JavaScript
const r = await system.tag.read("Line1/Filler/Motor1/Speed");
if (r.quality === "Good") console.log(r.value, r.meta.unit);

const readings = await system.tag.readAll([
"Line1/Filler/Motor1/Speed",
"Line1/Filler/Motor1/Current",
]);
Check quality before you act on a value

A Bad-quality read still returns the last known value. A script that ignores quality will happily compute an OEE figure from a number the PLC stopped sending an hour ago.

r = system.tag.read("Line1/Filler/Motor1/Speed")
if r["quality"] != "Good":
return None # end the branch rather than compute from a stale number

Writing​

Python
system.tag.write("Line1/Filler/SpeedSP", 2500)

system.tag.writeAll(
["Line1/Filler/SpeedSP", "Line1/Filler/Mode"],
[2500, "auto"],
)
JavaScript
try {
await system.tag.write("Line1/Filler/SpeedSP", sp);
context.ui.notify({ message: "Setpoint applied", severity: "success" });
} catch (e) {
context.ui.notify({ message: `Write rejected: ${e.message}`, severity: "error" });
}

A write passes two server-side checks, and a script cannot skip either:

  1. The tag must be writable.
  2. The caller's level must meet the tag's write level — which applies to system.tag.write exactly as it does to a write from a screen. Changed in 1.0.4

A rejected write is a normal outcome, not a bug. Say so on screen rather than leaving it silently unchanged.

Worked example — a guarded ramp​

Python node: move a setpoint 10% at a time, never past its limits
sp = system.tag.read("Line1/Filler/SpeedSP")
if sp["quality"] != "Good":
return None

target = float(payload["target"])
lo = sp["meta"]["eng_low"]
hi = sp["meta"]["eng_high"]

step = (target - sp["value"]) * 0.10
nxt = max(lo, min(hi, sp["value"] + step))

system.tag.write("Line1/Filler/SpeedSP", round(nxt, 1))
return {"from": sp["value"], "to": nxt, "target": target}

system.db​

SQL, saved named queries, stored procedures and transactions, against any database connection defined in the interface.

MethodSignatureReturnsScope
runQueryrunQuery(query, args?, connectionName?)Row[]db:write
runNamedQueryrunNamedQuery(name, params?)Row[]db:read
callProccallProc(statement, args?, connectionName?){ resultSets: Row[][] }db:write
transactionsee belowA handle, or the callback's resultdb:write

A Row is a map of column name to value. Connections are addressed by the name shown in the interface, not by an internal id.

Why runQuery needs the write scope

Arbitrary SQL may write, so the gate has to assume it does. A named query runs only the SQL its author saved with the project — a script can pass parameters but not change the statement — so it needs only db:read, which is why preferring named queries lets a script run at a lower privilege.

Changed in 1.0.8 From a browser, a caller who may not write — an anonymous visitor of a public project, or an operator below the Operate write level — can still run a named query that only reads: a single SELECT or WITH statement, with no SELECT INTO and no writing common table expression. A named query that writes is refused for them. Before 1.0.8 every named query was refused, which left a public dashboard unable to fill even a dropdown.

Placeholders are driver-native​

QUBIQ does not rewrite your SQL. Use the placeholder your database uses: ? for MySQL, SQL Server and SQLite, $1, $2… for PostgreSQL.

Python — PostgreSQL
rows = system.db.runQuery(
"SELECT id, units FROM production WHERE line = $1 AND ts >= $2",
["L1", "2026-08-01"],
"ReportingDB",
)
Python — MySQL / SQL Server / SQLite
rows = system.db.runQuery(
"SELECT id, units FROM production WHERE line = ? AND ts >= ?",
["L1", "2026-08-01"],
"ReportingDB",
)

Always pass arguments as arguments. String-formatting a value into the statement is a SQL injection, and it is one whatever the value came from.

# ❌ never
rows = system.db.runQuery(f"SELECT * FROM orders WHERE id = {payload['id']}")
# ✅
rows = system.db.runQuery("SELECT * FROM orders WHERE id = ?", [payload["id"]])

Named queries — the preferred route​

A named query is saved with the project, carries its own connection, and takes named parameters.

Python
rows = system.db.runNamedQuery("production_by_shift", {
"start": "2026-08-01T06:00:00Z",
"end": "2026-08-01T14:00:00Z",
})
total = sum(r["units"] for r in rows)
JavaScript
const rows = await system.db.runNamedQuery("production_by_shift", {
start: "2026-08-01T06:00:00Z",
end: "2026-08-01T14:00:00Z",
});

Three reasons to reach for one first: lower privilege, no injection surface, and one place to change the SQL when the schema moves.

Stored procedures​

callProc returns every result set the procedure produced, in order.

Python
res = system.db.callProc("CALL close_batch(?)", ["B-1042"], "MES")
summary = res["resultSets"][0]
warnings = res["resultSets"][1] if len(res["resultSets"]) > 1 else []

Transactions​

A transaction is the one part of system.db where the two languages differ in shape.

Python — a context manager​

Commits on a clean exit, rolls back on any exception
with system.db.transaction(connectionName="ERP", isolation="ReadCommitted") as tx:
rows = tx.runQuery("SELECT qty FROM inventory WHERE sku = ?", ["A1"])
if rows[0]["qty"] < 10:
raise ValueError("insufficient stock") # ← rolls back, nothing is written

tx.execute("UPDATE inventory SET qty = qty - ? WHERE sku = ?", [10, "A1"])
tx.execute("INSERT INTO movements (sku, qty) VALUES (?, ?)", ["A1", -10])

Ordinary Python runs between statements and the transaction stays open — that is the point of the handle over two separate calls. You may also drive it by hand:

tx = system.db.transaction(connectionName="ERP", timeoutMs=30000)
try:
tx.execute("UPDATE ...")
tx.commit()
except Exception:
tx.rollback()
raise
finally:
tx.close() # idempotent — rolls back if nothing finished it

transaction() raises if you give it neither connectionName nor connectionId.

JavaScript — managed, or a handle​

Managed (recommended) — commits on success, rolls back if the callback throws
await system.db.transaction(
{ connectionName: "ERP", isolation: "ReadCommitted" },
async (tx) => {
await tx.execute("UPDATE inventory SET qty = qty - ? WHERE sku = ?", [10, "A1"]);
await tx.execute("INSERT INTO movements (sku, qty) VALUES (?, ?)", ["A1", -10]);
},
);

Omit the callback and you get the handle instead, and drive commit() / rollback() yourself.

db.transaction is not available in a client script

The capability is deliberately withheld from the browser sandbox: a transaction is a stateful handle, and a tab that closes mid-transaction would strand it on the server until its TTL reaped it. Put transactional work in a Gateway script and call it with system.script.runOnGateway — then a disconnect can only lose the reply.

Options​

OptionMeaning
connectionNameThe connection's name in the interface. One of this or connectionId is required
connectionIdExplicit id — advanced
isolationReadUncommitted · ReadCommitted · RepeatableRead · Serializable · Snapshot
readOnlyOpen a read-only transaction
timeoutMsServer-side TTL. The transaction is rolled back automatically if it has not finished in time

The handle​

runQuery(query, args?) · execute(query, args?) · runNamedQuery(name, params?) · callProc(statement, args?) · commit() · rollback() · close() (Python)

execute returns {rowsAffected, lastInsertId}; runQuery returns rows.

Keep a transaction short

timeoutMs guarantees an abandoned transaction is rolled back, but a long one holds locks that everything else waits behind. Read what you need, decide, write, close.


system.hist​

Historian queries — the stored series, as opposed to system.tag's live value.

MethodSignatureReturnsScope
getTrendsgetTrends(paths, start, end, maxPoints?, agg?)A downsampled series per taghist:read
getStats 1.0.8getStats(paths, start, end, aggregates?, opts?)One number per tag per statistichist:read
valueAt 1.0.8valueAt(paths, time)Each tag's value at one instanthist:read

paths is one path or a list of them. Times are ISO-8601 strings or Unix milliseconds. A tag with no history is left out of the result rather than returned empty, so match results by path, not by position.

system.hist in Python before 1.0.8

Changed in 1.0.8 In a pipeline Python node or a Gateway script, every system.hist call returned None in earlier releases — the worker had no route to the historian. It returns data from 1.0.8. A script written to work around that, for example by treating None as "no history", should be revisited.

getTrends​

ParameterMeaning
start · endThe window
maxPointsDownsample target — the server aggregates to roughly this many points per series. About 2,000 when omitted
aggavg (default) or minmax

Returns one entry per series, t an ISO-8601 time and q the quality code (192 is Good):

[
{
"path": "Line1/Filler/Motor1/Speed",
"values": [
{ "t": "2026-08-01T00:00:00Z", "v": 1487.2, "q": 192 },
{ "t": "2026-08-01T00:01:00Z", "v": 1490.8, "q": 192 }
]
}
]

With agg: "minmax" each point also carries min and max — the band the samples spanned.

Python — a shift's average, and its worst excursion
series = system.hist.getTrends(
["Line1/Filler/Motor1/Speed"],
"2026-08-01T06:00:00Z",
"2026-08-01T14:00:00Z",
maxPoints=480,
agg="minmax",
)
pts = series[0]["values"]
avg = sum(p["v"] for p in pts) / len(pts)
worst = min(p.get("min", p["v"]) for p in pts)
Always pass maxPoints

A day of one-second data is 86,400 points per tag. Nobody reads them, and moving them is pure cost — on the historian, on the bus, and in the browser. Ask for what the chart can draw.

Use minmax when spikes matter: avg smooths away exactly the excursion you were looking for.

getStats New in 1.0.8​

One number per tag per statistic over the window [start, end) — a shift report's totals, without fetching the series and summing it yourself. The server computes them the way the Historian chart's statistics do.

aggregates entryIs
avg · min · max · count · sum · range · first · lastThe ordinary statistics
twaTime-weighted average
percentGoodShare of the window with Good quality
integralA rate totalled over time — a flow in m³/h becomes m³
counterIncreaseHow much an ever-rising counter rose, with rollover detected
onSec · offSecSeconds at non-zero / at zero
onCountNumber of starts

Omitted, aggregates is avg, min, max and count. For integral, the unit the value is a rate per — s, min, h (the default) or d — is opts.integralUnit in JavaScript and the integralUnit= keyword in Python. A statistic with no answer for the window is null / None. → What each statistic means: Statistics

Python — a shift's production and run time
stats = system.hist.getStats(
["Line1/Filler/Flow", "Line1/Filler/Motor1/Running"],
"2026-08-01T06:00:00Z",
"2026-08-01T14:00:00Z",
["integral", "onSec", "onCount"],
integralUnit="h",
)
by_path = {s["path"]: s["values"] for s in stats}
litres = by_path["Line1/Filler/Flow"]["integral"]
run_h = by_path["Line1/Filler/Motor1/Running"]["onSec"] / 3600
JavaScript
const stats = await system.hist.getStats(
["Line1/Filler/Flow"], start, end, ["integral", "twa"], { integralUnit: "h" },
);

Returns [{ path, values: { "<statistic>": number | null } }].

valueAt New in 1.0.8​

Each tag's value at one instant — the tank level at shift change, the counter at midnight.

Python
readings = system.hist.valueAt(["Line1/Tank1/Level"], "2026-08-01T06:00:00Z")
level = readings[0]["value"] # None when it was not known then
FieldMeaning
pathThe tag
valueThe value at that instant, or null / None when it was not known then — no recent row, or a Bad one
qualityThe quality of the row the value came from
sourceTimeWhen that row was sampled
interpolatedtrue when the value lies on the line between two rows rather than on a row

A tag that steps holds the value at or before the instant; a tag that interpolates linearly follows the line to the next value. → Querying history


system.vars​

Gateway variables — the server-side half of the Variables feature. A variable belongs to the project that set it and is shared by that project's pipelines, Gateway scripts and screens, unless it is declared global, when one value is shared by every project.

Changed in 1.0.8
MethodSignatureReturnsScope
getget(name, default?)The value, or the default when unsetvars:read
setset(name, value, storage?, scope?)truevars:write
deletedelete(name)truevars:write
keyskeys()This project's names and the global onesvars:read
incrincr(name, by?)The new value, atomicallyvars:write
decr 1.0.8decr(name, by?)The new value, after atomically subtracting byvars:write
listlist(){name, value, type, storage, scope, updatedAt}[]vars:read

storage is persistent (survives a restart) or volatile (in memory, cleared on restart). Omitted, the workspace default applies.

scope is project or global. Omitted, the name keeps the scope it has — this project's own, unless it was made global. scope: "global" declares the name global, and is refused while another project still holds its own variable of that name, so no value is silently discarded. scope: "project" is refused for a name that is global. A name is global everywhere or nowhere: a project never sees its own copy shadowing a global one. → Variables

incr and decr treat a step of 0 as 1.

Python — an ETL watermark that survives a restart
since = system.vars.get("etl_watermark", "1970-01-01T00:00:00Z")

rows = system.db.runNamedQuery("orders_since", {"since": since})
if not rows:
return None

newest = max(r["updated_at"] for r in rows)
system.vars.set("etl_watermark", newest, "persistent")
return rows
Use incr for counters, never read-then-write

incr is atomic. get followed by set is not, and two pipelines running at once will lose increments — silently, intermittently, and only under load.

# ❌ loses counts under concurrency
system.vars.set("processed", system.vars.get("processed", 0) + 1)
# ✅
count = system.vars.incr("processed")

system.vars is the gateway store. Its browser counterparts are context.vars (a read-only mirror) and context.client (tab-local state that never leaves the browser) — → Context API · Variables

In LiveView, a screen reads only the variables its project names

Changed in 1.0.8 Called from a LiveView by an operator or an anonymous visitor, get of a variable that none of the project's views or client scripts mention is refused, and keys and list return only the ones they do. A variable counts as mentioned when a view or client script contains a vars.<name> binding, vars["<name>"], or system.vars.get("<name>"). A newly bound variable becomes readable within 10 seconds of the view being saved. Designer users, pipelines and Gateway scripts are not limited.


system.files​

Read and write files in the workspace uploads folder. Paths are relative to the uploads root and nested folders are created as needed.

MethodPythonJavaScriptScope
save(path, data)data is bytes or str → returns the URL stringdata is base64 → returns {path, url}file:write
read(path)Returns bytesReturns {data, path, url, size}, data base64file:read
size(path)Byte size, without transferring the fileSamefile:read
delete(path)True{ok, path}file:write
read_chunks(path, chunk_size?)Yields bytes a window at a time—file:read
readChunk(path, offset?, length?)—One window: {data, path, url, offset, size, eof}file:read
Python — write a CSV a screen can link to
lines = ["shift,units,scrap"]
lines += [f"{r['shift']},{r['units']},{r['scrap']}" for r in rows]
url = system.files.save("reports/2026/08/shift-a.csv", "\n".join(lines))
# url → "/api/uploads/reports/2026/08/shift-a.csv"
JavaScript — read a template and show it
const f = await system.files.read("templates/label.png"); // f.data is base64
context.find("Image_Label").props.src = f.url;

The returned url is directly usable by an Image, PDF Viewer or Link component — which is how a script-generated report reaches a screen without anything else being wired up.

Changed in 1.0.8 system.files works from a client script. In earlier releases the call failed there with "Module not found: files". An anonymous visitor of a public project is refused, as the File Manager's upload is.

Large files​

A single reply cannot carry more than 4 MiB of file, so both runtimes stream underneath. read() is chunked for you and returns the whole file — but the whole file then sits in memory. For anything genuinely large, iterate instead:

Python — constant memory, however big the file
rows = 0
for chunk in system.files.read_chunks("scans/big.csv"):
rows += chunk.count(b"\n")
JavaScript — the same loop, one window at a time
let offset = 0, lines = 0;
for (;;) {
const c = await system.files.readChunk("scans/big.csv", offset);
const bytes = atob(c.data);
lines += (bytes.match(/\n/g) || []).length;
offset += bytes.length;
if (c.eof) break;
}
delete takes a file, never a folder

A folder is refused server-side. Deleting one takes everything inside it and a script has no confirmation step — that belongs in the File Manager, where a human confirms.


system.message Changed in 1.0.8​

The message bus: send a message on a dotted subject, or send one and wait for the reply. It reaches pipeline Message In nodes and every browser's On message handlers whose pattern matches. The call shape is the same in Python and JavaScript, and the same as context.message in the browser.

MethodSignatureReturnsScope
sendsend(subject, payload?, opts?)true, once the message is on the busscript:execute
requestrequest(subject, payload?, opts?)The reply's payloadscript:execute
OptionMeaning
scopeall — every project (the default) · project — this script's project only
timeoutMsrequest only. Default 5000, 100–60000

subject is literal segments of letters, digits, _ and -, separated by dots (line1.motor.start). The payload is any JSON value up to 256 KB. The project is always the one the script runs in — there is no option to name another.

Python — tell this project's screens a report is ready
url = system.files.save(f"reports/{shift}.csv", csv_text)
system.message.send("report.ready", {"shift": shift, "url": url}, {"scope": "project"})
JavaScript — ask a pipeline, and show why it failed
try {
const status = await system.message.request("motor.status", { id: "m1" }, { scope: "project" });
context.ui.notify({ message: `Motor is ${status.state}`, severity: "info" });
} catch (err) {
// err.details is set when the pipeline's Message Reply was fed by a Catch node
context.ui.notify({ message: err.message, severity: "error" });
}

request raises (Python) or rejects (JavaScript) on a timeout, when nobody is listening, or when the receiver rejects the request. A rejection sent by a Message Reply wired to a Catch node carries the caught error as details — details.error.message, details.error.traceback, details.source.name (the node that failed). In Python the exception is a SystemCallError with a details attribute.

Changed from 1.0.7

system.message.send used to push from the gateway to browsers only, by a free-text type, with the options projectId, userId and from. Those options are gone, and scope: "user" now fails — the bus cannot deliver to one user; send with project and put the user in the payload for the receiver to check. A type containing a space, : or / is refused. And a send with no scope now reaches every project. → What changed


system.script​

Run a server-side Gateway script entry point and return its result. From a client script it moves work off the browser; from a pipeline Python node 1.0.8 it runs a project's shared server-side logic.

MethodSignatureScope
runOnGatewayrunOnGateway(handle, inputs?, opts?)script:execute
ParameterMeaning
handle"<file>.<function>", naming a @gateway-decorated function
inputsPassed to it as its argument
idempotencyKeyDeduplicate repeated calls
idempotencyTTLHow long that key is remembered, in seconds. Default 7200 (2 h)
timeoutMsHow long the caller waits. JavaScript: 15000 by default. Python: the node's own remaining budget, or 35000 outside one

The entry point always runs in the caller's own project, resolved server-side. JavaScript's type definitions still list an opts.projectId; the server ignores it, and always has.

In JavaScript the options are an object; in Python they are keyword arguments:

Python node — reuse the project's Gateway logic
res = system.script.runOnGateway(
"batches.releaseBatch",
{"batchId": payload["batchId"], "by": "pipeline"},
idempotencyKey=f"release-{payload['batchId']}",
)
return res

Python's call runs the entry point in a separate worker, so it holds a second worker while the caller waits. It carries the calling script's own scopes and write level, and it is refused inside a Gateway entry point — that code is already server-side, so import the function instead of calling back into the gateway.

A button that releases a batch
const batchId = context.find("Table_Batches").props.selectedRow?.id;
if (!batchId) {
context.ui.notify({ message: "Select a batch first", severity: "warning" });
return;
}

try {
const res = await system.script.runOnGateway(
"batches.releaseBatch",
{ batchId, by: context.session.auth.username },
{ idempotencyKey: `release-${batchId}`, timeoutMs: 60000 },
);
context.ui.notify({ message: `Released ${res.lot}`, severity: "success" });
context.refreshBinding("view");
} catch (e) {
context.ui.notify({ message: `Release failed: ${e.message}`, severity: "error" });
}
Scripts/Gateway/batches.py — the other end
@gateway
def releaseBatch(inputs):
with system.db.transaction(connectionName="MES") as tx:
rows = tx.runQuery("SELECT lot, state FROM batches WHERE id = ?", [inputs["batchId"]])
if not rows:
raise ValueError("no such batch")
if rows[0]["state"] != "READY":
raise ValueError(f"batch is {rows[0]['state']}, not READY")

tx.execute("UPDATE batches SET state = 'RELEASED' WHERE id = ?", [inputs["batchId"]])
tx.execute("INSERT INTO batch_audit (id, action, actor) VALUES (?, 'release', ?)",
[inputs["batchId"], inputs["by"]])
return {"lot": rows[0]["lot"]}

Two things that example is doing deliberately:

  • The rule lives on the server. "Only a READY batch may be released" is checked in the Gateway script, where the operator cannot skip it. In the client script it would be decoration.
  • The idempotency key makes a double-click harmless. The second call returns the first call's result instead of releasing anything twice.

Changed in 1.0.4 A Gateway script called from a browser now runs with a capability grant derived from its caller — a script may not do what its caller could not do directly. → The sandbox


The pipeline Python globals​

A Python node gets more than system. These exist only there — not in a Gateway script, and not in the browser.

GlobalIs
payload · dataThe incoming message's payload. Two names for the same object
metadataThe message's metadata map
messageThe whole envelope — payload, metadata, topic, origin
log(msg) · print(...)Write a line to the execution log for this run
progress(percent, message="")Report progress on a long-running node
retry_transient(fn, retries=3, delay=1, backoff=2)Call fn() again on a transient failure, backing off between attempts
cacheA dict scoped to the worker process, surviving between runs
cache_get_or_create(key, factory)Build a value once and reuse it — the way to load an ML model
math · json · datetimeStandard library, plus anything an administrator installed
Load a model once, not once per message
model = cache_get_or_create("anomaly_v3", lambda: joblib.load("/models/anomaly_v3.joblib"))
score = model.predict([[payload["temp"], payload["vibration"]]])[0]
return {"score": float(score), "flagged": bool(score > 0.8)}

Returning None ends that branch — a clean way to filter without an extra node.


Scopes at a glance​

Every method maps to exactly one capability scope, enforced at the worker.

MethodScope
tag.read, tag.readAlltag:read
tag.write, tag.writeAlltag:write
db.runNamedQuerydb:read
db.runQuery, db.callProc, every transaction calldb:write
hist.getTrends, hist.getStats, hist.valueAthist:read
vars.get, vars.keys, vars.listvars:read
vars.set, vars.delete, vars.incr, vars.decrvars:write
files.read, files.size, files.readChunkfile:read
files.save, files.deletefile:write
message.send, message.request, script.runOnGatewayscript:execute

A method with no explicit mapping falls back to its namespace's strong scope, so a newly added API is gated by default rather than accidentally open.

→ The script sandbox

Next​

→ Context API reference · Python nodes · Gateway scripts