Skip to main content
Version: 1.0.4

Gateway Scripts

A Gateway script is a Python module in a project's Scripts → Gateway folder that exposes one or more entry points to the rest of the system. It runs server-side, so it is where transactional and long-running work belongs.

Why server-side

Client scriptGateway script
Runs in the operator's browserRuns on the gateway
A disconnect abandons the work mid-flightA disconnect can only lose the reply
An open transaction can be strandedThe transaction completes or rolls back regardless
Bounded by the browser's sessionBounded by the server's timeout

If a script opens a database transaction, writes several tables, or takes more than a moment, it belongs here.

The @gateway decorator

Only functions marked @gateway are callable. Everything else in the module is a private helper — default-deny, and it is a security boundary, not a convention.

Scripts/Gateway/reports.py
@gateway
def generateShiftReport(inputs):
shift = inputs["shift"]
rows = system.db.runNamedQuery("production_by_shift", {
"start": inputs["start"],
"end": inputs["end"],
})
csv = _to_csv(rows) # private helper — NOT callable
url = system.files.save(f"reports/{shift}.csv", csv) # Python: returns the URL
return {"url": url, "rows": len(rows)}


@gateway(timeout_ms=120000)
def rebuildAggregates(inputs):
...


def _to_csv(rows): # no decorator ⇒ not an entry point
...
  • Bare @gateway marks an entry point.
  • Parameterised @gateway(timeout_ms=...) sets per-entry-point options.
  • gateway is injected by the runtime — no import needed.
  • Exports are discovered by a static AST scan, without executing the module, which is what drives the Designer's IntelliSense.

Calling one

From a client script or event action
const res = await system.script.runOnGateway(
"reports.generateShiftReport",
{shift: "A", start: "2026-08-01T06:00:00Z", end: "2026-08-01T14:00:00Z"},
{idempotencyKey: "shift-A-2026-08-01", timeoutMs: 60000},
);
context.ui.notify({ message: `Report ready: ${res.rows} rows`, severity: "success" });

The handle is "<file>.<function>". The active project is resolved server-side; pass opts.projectId only to override it.

There is no system.script in Python

runOnGateway exists to move work off the browser, so it is a JavaScript call. A pipeline Python node and a Gateway script are already server-side — to share logic between them, put it in a module and import it, or route the work through a Link node.

Idempotency

{idempotencyKey: "shift-A-2026-08-01", idempotencyTTL: 7200}

With a key set, repeated calls within the TTL (default 7200 s / 2 h) return the cached result instead of re-running. A double-clicked button produces one report, not two.

Choose a key that identifies the work, not the click: shift-A-2026-08-01 is right; a random value defeats the purpose.

Transactions

The reason Gateway scripts exist:

@gateway
def releaseBatch(inputs):
def work(tx):
tx.execute("UPDATE batches SET status='released' WHERE id=?", [inputs["batchId"]])
tx.execute("INSERT INTO batch_audit (batch_id, action, actor) VALUES (?,?,?)",
[inputs["batchId"], "release", inputs["actor"]])
return tx.runQuery("SELECT * FROM batches WHERE id=?", [inputs["batchId"]])

rows = system.db.transaction({"connectionName": "MES",
"isolation": "ReadCommitted",
"timeoutMs": 30000}, work)
return {"batch": rows[0]}

The managed form commits on success and rolls back if the callback raises. Combined with the server-side timeoutMs, an abandoned transaction cannot hold locks indefinitely.

Patterns

Validated write, gated server-side

@gateway
def applyRecipe(inputs):
recipe = system.db.runNamedQuery("recipe_by_id", {"id": inputs["recipeId"]})
if not recipe:
return {"ok": False, "error": "unknown recipe"}

r = recipe[0]
if not (0 <= r["speed"] <= 3000):
return {"ok": False, "error": "recipe out of range"}

system.tag.writeAll(
["Line1/Filler/SpeedSP", "Line1/Filler/TempSP"],
[r["speed"], r["temperature"]],
)
return {"ok": True, "applied": r["name"]}

The client cannot bypass the validation because the client never writes the tags.

Report generation with delivery

@gateway(timeout_ms=180000)
def monthlyReport(inputs):
rows = system.db.runNamedQuery("monthly_summary", inputs)
saved = system.files.save(f"reports/{inputs['month']}.csv", _to_csv(rows))
system.message.send("reportReady", {"url": saved["url"]},
{"scope": "project", "projectId": inputs["projectId"]})
return {"url": saved["url"]}

Long work, a generated artifact, and a push back to every browser on the project.

Security

  • Default-deny exports. An undecorated function can never be invoked from a client, even by exact name.
  • Validate inputs. They arrive from a browser. Treat them as untrusted.
  • Capability scopes still apply — see Python nodes.
  • Do the authorisation here. A Gateway script is the right place to enforce "only a supervisor may release a batch", because the client cannot skip it.

Client scripts vs Gateway scripts

Use a client scriptUse a Gateway script
Screen interaction, validation, UI stateDatabase transactions
Reading a tag to decide what to showMulti-step writes that must be atomic
A quick single tag writeWork that takes more than a moment
Anything purely presentationalAnything the operator must not be able to bypass

Next

Client scripts