Live WebSocket (/sync)
/sync is the gateway's live channel: one WebSocket that carries tag values as they change, writes, history reads, replays, alarm frames and screen event scripts. This page is for anyone talking to it directly; the TypeScript SDK does all of this for you.
Connecting
Open wss://<host>/sync (or ws:// when the gateway is served over plain HTTP). The gateway accepts the upgrade when:
- The request carries a signed-in session. In a browser the session cookie is sent with the upgrade, so a page served from the same origin needs nothing else. A browser's
WebSocketcannot set headers, so an API key cannot be used from a page. API keys are for the REST API, not for live data: a non-browser client that putsAuthorization: Bearer <key>on the upgrade request is accepted and listed on Gateway › Clients as anapiKeyclient, but the gateway re-checks the role of an open socket through its session, and a key has none. That socket is closed at the next periodic check (within 30 seconds) andtag.writeon it is refused. Use a signed-in session for/sync. - The
Originheader is one ofAUTH_TRUSTED_ORIGINS. A missing, empty ornullorigin is refused. Browsers send cookies on cross-site WebSocket upgrades, so this check is what stops another page from driving a signed-in session. - The principal holds at least the
viewerrole. Subscribing and replaying need nothing more;tag.writeneedsoperator.
| Refusal | Status |
|---|---|
| Not a WebSocket upgrade | 400 |
| No session or API key | 401 |
Untrusted Origin | 403 |
| The user already holds 32 sockets | 429 |
Once open, the socket re-reads the session every 30 seconds and closes (close status 1008, policy violation) when the session has ended or the role is no longer a known one. A write checks the stored role on its own, so a demoted operator's next write fails at once.
Frame format
Every message in either direction is one JSON text frame of the form {"event": "<name>", "data": <payload>}. The gateway sends one frame per message. An inbound frame larger than 1 MiB closes the socket (close status 1009). Tag paths are at most 512 characters. A frame the gateway cannot handle (unknown event, missing field, bad value) is logged and ignored; it does not end the session. The one exception is a failed tag.write, which is answered with tag.write.error.
A device can report NaN; the gateway writes it as the bare token NaN, which JSON.parse in a browser rejects. Byte values are sent as base64 strings and JSON-kind values as the parsed JSON.
Client frames and replies
| Client sends | Gateway replies |
|---|---|
tag.subscribe {path} | tag.value now if the tag has a value, then on every change |
tag.unsubscribe {path} | Nothing |
tag.write {path, value} | tag.write when accepted, or tag.write.error {path, error} |
tag.replay {path, start, end, maxPoints?} | tag.replay {...} or tag.replay.error {path, start, end, error} |
replay.start {id?, start, end?, paths?} | replay.status, then replay.value and replay.alarm frames |
replay.pause {}, replay.resume {}, replay.stop {} | replay.status |
alarm.subscribe {} | alarm.snapshot {alarms}, then alarm.* frames |
alarm.unsubscribe {} | Nothing |
screen.event {requestId, screenId, event, ...} | screen.event {requestId, ok, directives, errors} |
The gateway also sends one unsolicited frame, tags.changed {parents}, described below.
tag.subscribe and tag.value
tag.subscribe {path: string} adds the path to the socket's subscriptions. Values reach a subscriber only after the event backbone has durably accepted them. Each socket may hold 10,000 subscriptions; the 10,001st is refused and nothing is sent. Subscribing twice to the same path is harmless.
tag.value data:
| Field | Type | Meaning |
|---|---|---|
path | string | The tag path |
value | any | The engineering value (after scaling). Structures are JSON objects, bytes a base64 string |
quality | string | Good, Uncertain or Bad |
timestamp | number | Unix milliseconds |
rawValue? | any | The source value before scaling. Present only when a scaling transform applied |
qualityDetail? | string | Why the quality is not Good: a script error, a lost link |
private? | boolean | true when the value belongs to this user or session (a scoped memory tag). A replay never holds those |
Values under @gateway.browser.* are held by the socket alone: a tag.write to such a path stores the value on the session (at most 256 values of 64 KiB each), echoes it back as a tag.value with quality Good, and needs no role beyond viewer. They are for a browser reporting its own state.
tag.write
tag.write {path: string, value: any} writes a value. The reply is tag.write whose data is the string "Write accepted for tag: <path>", or tag.write.error {path, error} with one of these errors:
| Error | Cause |
|---|---|
Your role doesn't allow writing to tags. | The stored role is below operator |
Replay is on. Return to live to change values. | The socket is in a replay. Private values and @gateway.browser.* stay writable |
The write timed out. | The device did not answer within 15 seconds |
This session has too much browser data. | The @gateway.browser.* limits above |
The gateway could not complete this write. | Anything else that failed |
| The tag's own reason | The runtime or driver rejected the write |
Every accepted write is recorded in the audit log.
tag.replay (history read)
tag.replay {path: string, start: number, end: number, maxPoints?: number} reads a tag's history for a range in Unix milliseconds; maxPoints defaults to 1000. The answer comes from a running history module when there is one, otherwise from what the event backbone retains; when the module fails or takes longer than 15 seconds, the backbone answers instead. The reply is tag.replay with:
| Field | Type | Meaning |
|---|---|---|
path, start, end | Echoed from the request | |
points | {timestamp, value, quality}[] | The values in the range, reduced to maxPoints |
preBoundary | {timestamp, value, quality} | null | The last value before start, so a chart can draw from the left edge |
samples | number | Stored samples in the range before any reduction |
source | string | The module's id, or backbone |
retainedFrom | number | null | The oldest time the source has; a range starting earlier has no data before this |
truncated | boolean | The read stopped short of end, at coveredUntil. The backbone reads at most 10,000 stored batches from the start of a range |
coveredUntil | number | null | The newest time the source read or holds |
catchingUp | boolean | The module is behind the live stream, so recent values may be missing |
gaps | {from, to}[] | null | Periods the module never received |
fallbackReason | string | null | Why an installed module did not answer |
On failure the reply is tag.replay.error {path, start, end, error} with the error The gateway could not read this tag's history. See History and replay for what the sources keep.
Replay
A replay plays the recorded past back to one socket: the tag values, the gateway clock (@gateway.time) and the alarms. It is private to the socket; its frames are replay.value and replay.alarm, never tag.value or alarm.*, so live state and other clients are untouched.
replay.start data:
| Field | Type | Meaning |
|---|---|---|
id? | string | Your id for the replay, up to 128 characters; the gateway makes one when it is missing. Every replay frame carries it |
start | number | Unix milliseconds. Required |
end? | number | Unix milliseconds. Without it the replay runs until it has caught up with the present |
paths? | string[] | Exact tag paths, or prefixes ending in *. Without it every tag the session may read is replayed |
The gateway then sends what every tag held at start as replay.value frames with initial: true, the clock at start, the alarms outstanding at start as a replay.alarm with kind: "snapshot", and a replay.status with opened: true. From there it plays the recording at the recorded pace (gaps longer than 5 seconds are shortened to 5), with the clock once a second and each alarm event at its recorded time. Replays cannot be sped up or slowed down. Starting a second replay ends the first; the gateway runs at most 8 at once. Tags private to a user or session are never replayed, and @gateway tags other than the clock keep their live value. Closing the socket ends the replay.
replay.value {id, path, value, quality, timestamp, rawValue?, qualityDetail?, initial?} has the fields of tag.value plus the replay id and initial.
replay.alarm is either {id, kind: "snapshot", alarms: Alarm[]} (most severe first) or {id, kind, alarm, at} where kind is active, acknowledged, cleared, shelved or unshelved, alarm is the alarm in the state the event left it, and at is when it was recorded.
replay.status {id, state, start, end, position, sent, opened, message?, warning?} is sent on every change and about four times a second while playing:
state | Meaning |
|---|---|
playing | Following the recording |
paused | After replay.pause, until replay.resume |
finished | The end, or the end of the recording, was reached. The socket is still in replay |
stopped | After replay.stop |
error | The gateway stopped or refused it; message says why, for example A start time in Unix milliseconds is required. or The gateway is already running as many replays as it can. |
position is the recorded time reached, sent how many values have gone out, opened whether the opening state is complete, and warning something that did not stop the replay but a person should know, such as alarm history that could not be read.
replay.pause, replay.resume and replay.stop take {} as data. A socket is in replay from replay.start until replay.stop, finished included, and during that time tag.write and screen.event are refused.
Alarms
alarm.subscribe {} answers with alarm.snapshot {alarms: Alarm[]}, the outstanding alarms, then one frame per change with the alarm (the full object from the alarms API) as data:
| Frame | When |
|---|---|
alarm.active | The alarm went active |
alarm.acknowledged | Someone acknowledged it; see acknowledgedBy and acknowledgedByName |
alarm.cleared | The condition ended. state says whether it still awaits an acknowledgement |
alarm.updated | It went stale or recovered, or an outstanding alarm's definition changed |
alarm.removed | Its definition or tag was deleted |
A frame whose alarm has state: "normal" is no longer outstanding. alarm.unsubscribe {} stops the frames. See Alarms for the states.
screen.event
screen.event runs a stored screen event script on the gateway. Only the address of the script travels; its source never leaves the server.
| Field | Type | Meaning |
|---|---|---|
requestId | string | Up to 128 characters, echoed in the reply so you can match it. Required |
screenId | string | The screen (the API calls them views) |
componentLocalId? | string | The component whose event fires; absent for a screen-level event |
event | string | The event name |
actionPath? | number[] | Index path to one action inside the event, through then chains |
paramValues? | Record<string, string> | Values for the screen's parameters |
value? | any | The event payload the script sees as event.value |
The reply is screen.event {requestId, ok, directives, errors}. directives is a list of {toast?, navigate?, openPopup?, openUrl?} objects the script returned; errors is a list of strings. A connection runs at most 8 scripts at once (This connection is already running too many scripts.), and a socket in replay gets ok: false with Replay is on. Return to live to change values.. Scripts run with the session's identity and role; see Screen scripts.
tags.changed
The gateway sends tags.changed {parents: string[] | null} to every open socket when the tag tree changes: tags added, removed, renamed or edited by anyone, or a folder the gateway fills itself (such as @gateway.clients) gaining or losing a child. parents names the folders whose children changed, "" being the root; null means reload everything you show. Changes within 300 ms go out as one frame.
Backpressure and limits
Each socket has an outbound queue of 4,096 frames. When a client reads too slowly the oldest queued frames are dropped rather than slowing the runtime, so a subscriber can miss values. When the queue holds more than 32 MiB of text the socket is closed (close status 1008). The Clients page shows each socket's dropped-frame count.
| Limit | Value |
|---|---|
| Sockets per user | 32 |
| Subscriptions per socket | 10,000 |
| Inbound frame | 1 MiB |
| Tag path | 512 characters |
| Session re-check | Every 30 seconds; the socket closes within 30 seconds of the session ending |
| Write timeout | 15 seconds |
| Running screen scripts per socket | 8 |
| Replays per gateway | 8; one per socket |
| Outbound queue | 4,096 frames, 32 MiB |
Reconnecting
The gateway keeps no state for a closed socket. After reconnecting:
- Send
tag.subscribeagain for every path; the gateway answers each with its current value. - Send
alarm.subscribeagain; thealarm.snapshotcovers whatever changed while you were away. - Because a slow socket can lose frames, anything that must stay exact should also re-read
GET /api/v1/alarms/activenow and then. The SDK'suseAlarmsdoes this every 30 seconds. - A replay died with the socket. Send
replay.startagain from the lastpositionyou saw.
The SDK does all four itself and retries the connection every 3 seconds.
Example
A page served from the gateway's origin (so the session cookie and Origin are right) that follows one tag and writes it:
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${proto}//${location.host}/sync`);
const send = (event, data) => socket.send(JSON.stringify({ event, data }));
socket.onopen = () => {
send("tag.subscribe", { path: "Line1.Oven.Temperature" });
send("alarm.subscribe", {});
};
socket.onmessage = ({ data }) => {
const { event, data: payload } = JSON.parse(data);
switch (event) {
case "tag.value":
console.log(payload.path, payload.value, payload.quality, new Date(payload.timestamp));
break;
case "tag.write.error":
console.warn(`Write to ${payload.path} refused: ${payload.error}`);
break;
case "alarm.snapshot":
console.log(`${payload.alarms.length} alarms outstanding`);
break;
default:
if (event.startsWith("alarm.")) console.log(event, payload.tagPath, payload.state);
}
};
// Needs the operator role.
function setSetpoint(value) {
send("tag.write", { path: "Line1.Oven.Setpoint", value });
}
For a React application, use the TypeScript SDK instead; it adds reconnection, reference-counted subscriptions, optimistic writes and replay routing on top of these frames.