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, pushing a message to 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
| Context | Language | system is | Notes |
|---|---|---|---|
| Python node in a pipeline | Python | Synchronous | Plus the pipeline globals listed below |
| Gateway script | Python | Synchronous | Where transactional and long-running work belongs |
| Client script — event action or project library | JavaScript | Promise-returning | db.transaction is not available here; see Transactions |
| Binding transform | JavaScript | Promise-returning | Keep 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:
| Python | JavaScript | |
|---|---|---|
| Calls | Return a value | Return a promise — await them |
| Field access | r["value"] | r.value |
system.script | Not present | Present |
system.db.transaction | Keyword arguments, used with with | An options object, with or without a callback |
system.files | Returns and accepts bytes | Returns and accepts base64 strings |
await is not optional in JavaScriptForgetting 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.
| Method | Signature | Returns | Scope |
|---|---|---|---|
read | read(pathOrPaths) | One TagResult, or a list when given a list | tag:read |
readAll | readAll(paths) | TagResult[] | tag:read |
write | write(path, value) | true | tag:write |
writeAll | writeAll(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
| Field | Meaning |
|---|---|
value | The current value |
quality | Good · Bad · Uncertain · NotFound |
source_timestamp | When the device sampled it |
server_timestamp | When the server processed it |
raw_value | The value before scaling |
status_code | The protocol's own status code |
tag_id · name · tag_path | Identity |
data_type · unit | Type and engineering unit |
binding_type · connection_name | Where it came from |
array_dimensions | Present for array tags |
meta | The 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
r = system.tag.read("Line1/Filler/Motor1/Speed")
if r["quality"] == "Good":
log(f"{r['value']} {r['meta']['unit']}")
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"]
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",
]);
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
system.tag.write("Line1/Filler/SpeedSP", 2500)
system.tag.writeAll(
["Line1/Filler/SpeedSP", "Line1/Filler/Mode"],
[2500, "auto"],
)
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:
- The tag must be writable.
- The caller's level must meet the tag's write level — which
applies to
system.tag.writeexactly 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
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.
| Method | Signature | Returns | Scope |
|---|---|---|---|
runQuery | runQuery(query, args?, connectionName?) | Row[] | db:write |
runNamedQuery | runNamedQuery(name, params?) | Row[] | db:read |
callProc | callProc(statement, args?, connectionName?) | { resultSets: Row[][] } | db:write |
transaction | see below | A handle, or the callback's result | db: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.
runQuery needs the write scopeArbitrary SQL may write, so the gate has to assume it does. A named query cannot, so it needs
only db:read — which is why preferring named queries lets a script run at a lower privilege.
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.
rows = system.db.runQuery(
"SELECT id, units FROM production WHERE line = $1 AND ts >= $2",
["L1", "2026-08-01"],
"ReportingDB",
)
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.
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)
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.
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
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
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 scriptThe 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
| Option | Meaning |
|---|---|
connectionName | The connection's name in the interface. One of this or connectionId is required |
connectionId | Explicit id — advanced |
isolation | ReadUncommitted · ReadCommitted · RepeatableRead · Serializable · Snapshot |
readOnly | Open a read-only transaction |
timeoutMs | Server-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.
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 trend queries — the stored series, as opposed to system.tag's live value.
| Method | Signature | Scope |
|---|---|---|
getTrends | getTrends(paths, start, end, maxPoints?, agg?) | hist:read |
| Parameter | Meaning |
|---|---|
paths | One path, or a list of them |
start · end | ISO-8601 timestamps |
maxPoints | Downsample target — the server aggregates to roughly this many points per series |
agg | avg (default) or minmax |
Returns one entry per series:
[
{
"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.
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)
maxPointsA 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.
system.vars
Shared variables — the Gateway half of the Variables panel. The same store is visible to every pipeline, every Gateway script, and every screen in the workspace.
| Method | Signature | Returns | Scope |
|---|---|---|---|
get | get(name, default?) | The value, or the default when unset | vars:read |
set | set(name, value, storage?) | true | vars:write |
delete | delete(name) | true | vars:write |
keys | keys() | string[] | vars:read |
incr | incr(name, by?) | The new value, atomically | vars:write |
list | list() | {name, value, type, storage, updatedAt}[] | vars:read |
storage is persistent (survives a restart) or volatile (in memory, cleared on restart).
Omitted, the workspace default applies.
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
incr for counters, never read-then-writeincr 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
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.
| Method | Python | JavaScript | Scope |
|---|---|---|---|
save(path, data) | data is bytes or str → returns the URL string | data is base64 → returns {path, url} | file:write |
read(path) | Returns bytes | Returns {data, path, url, size}, data base64 | file:read |
size(path) | Byte size, without transferring the file | Same | file: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 |
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"
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.
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:
rows = 0
for chunk in system.files.read_chunks("scans/big.csv"):
rows += chunk.count(b"\n")
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 folderA 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
The gateway → browser push. It fans a typed message down to browser clients, where every component with an On message handler for that type fires.
| Method | Signature | Scope |
|---|---|---|
send | send(messageType, payload?, opts?) | script:execute |
| Option | Meaning |
|---|---|
scope | all (default) · project · user |
projectId | The target project, when scope is project |
userId | The target user, when scope is user |
from | A sender label, for the receiving handler |
url = system.files.save(f"reports/{shift}.pdf", pdf_bytes)
system.message.send(
"shiftReportReady",
{"shift": shift, "url": url},
{"scope": "project", "projectId": project_id},
)
// messageType: shiftReportReady
context.find("Link_Report").props.href = payload.url;
context.ui.notify({ message: `${payload.shift} shift report is ready`, severity: "success" });
system.message.send is gateway → browser and crosses the wire.
context.message.send is browser-local, component to
component, and never leaves the tab. Reaching for the wrong one is the usual cause of "my handler
never fires".
system.script
Run a server-side Gateway script entry point. JavaScript only — a Python script is already server-side, so there is nothing to hand off to.
| Method | Signature | Scope |
|---|---|---|
runOnGateway | runOnGateway(handle, inputs?, opts?) | script:execute |
| Parameter | Meaning |
|---|---|
handle | "<file>.<function>", naming a @gateway-decorated function |
inputs | Passed to it as its argument |
opts.projectId | Override the active project — otherwise resolved server-side |
opts.idempotencyKey | Deduplicate repeated calls |
opts.idempotencyTTL | How long that key is remembered, in seconds. Default 7200 (2 h) |
opts.timeoutMs | How long the browser waits. Default 15000 |
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" });
}
@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
READYbatch 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.
| Global | Is |
|---|---|
payload · data | The incoming message's payload. Two names for the same object |
metadata | The message's metadata map |
message | The 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 |
cache | A 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 · datetime | Standard library, plus anything an administrator installed |
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.
| Method | Scope |
|---|---|
tag.read, tag.readAll | tag:read |
tag.write, tag.writeAll | tag:write |
db.runNamedQuery | db:read |
db.runQuery, db.callProc, every transaction call | db:write |
hist.getTrends | hist:read |
vars.get, vars.keys, vars.list | vars:read |
vars.set, vars.delete, vars.incr | vars:write |
files.read, files.size, files.readChunk | file:read |
files.save, files.delete | file:write |
message.send, script.runOnGateway | script: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.