Skip to main content
Version: 1.0.4

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

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.scriptNot presentPresent
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)truepaths[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 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.

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 trend queries — the stored series, as opposed to system.tag's live value.

MethodSignatureScope
getTrendsgetTrends(paths, start, end, maxPoints?, agg?)hist:read
ParameterMeaning
pathsOne path, or a list of them
start · endISO-8601 timestamps
maxPointsDownsample target — the server aggregates to roughly this many points per series
aggavg (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.

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.


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.

MethodSignatureReturnsScope
getget(name, default?)The value, or the default when unsetvars:read
setset(name, value, storage?)truevars:write
deletedelete(name)truevars:write
keyskeys()string[]vars:read
incrincr(name, by?)The new value, atomicallyvars:write
listlist(){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.

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


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 timefile: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.

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

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.

MethodSignatureScope
sendsend(messageType, payload?, opts?)script:execute
OptionMeaning
scopeall (default) · project · user
projectIdThe target project, when scope is project
userIdThe target user, when scope is user
fromA sender label, for the receiving handler
Python — tell the screens a report is ready
url = system.files.save(f"reports/{shift}.pdf", pdf_bytes)
system.message.send(
"shiftReportReady",
{"shift": shift, "url": url},
{"scope": "project", "projectId": project_id},
)
The component that receives it — an On message handler
// messageType: shiftReportReady
context.find("Link_Report").props.href = payload.url;
context.ui.notify({ message: `${payload.shift} shift report is ready`, severity: "success" });
Two different messaging systems, on purpose

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.

MethodSignatureScope
runOnGatewayrunOnGateway(handle, inputs?, opts?)script:execute
ParameterMeaning
handle"<file>.<function>", naming a @gateway-decorated function
inputsPassed to it as its argument
opts.projectIdOverride the active project — otherwise resolved server-side
opts.idempotencyKeyDeduplicate repeated calls
opts.idempotencyTTLHow long that key is remembered, in seconds. Default 7200 (2 h)
opts.timeoutMsHow long the browser waits. Default 15000
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.getTrendshist:read
vars.get, vars.keys, vars.listvars:read
vars.set, vars.delete, vars.incrvars:write
files.read, files.size, files.readChunkfile:read
files.save, files.deletefile:write
message.send, 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