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 script | Gateway script |
|---|---|
| Runs in the operator's browser | Runs on the gateway |
| A disconnect abandons the work mid-flight | A disconnect can only lose the reply |
| An open transaction can be stranded | The transaction completes or rolls back regardless |
| Bounded by the browser's session | Bounded 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.
@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
@gatewaymarks an entry point. - Parameterised
@gateway(timeout_ms=...)sets per-entry-point options. gatewayis 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
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.
system.script in PythonrunOnGateway 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 script | Use a Gateway script |
|---|---|
| Screen interaction, validation, UI state | Database transactions |
| Reading a tag to decide what to show | Multi-step writes that must be atomic |
| A quick single tag write | Work that takes more than a moment |
| Anything purely presentational | Anything the operator must not be able to bypass |