A small Node.js server that bridges an RTI XP Series processor to any number of browsers or apps over WebSocket - buttons, sliders, live feedback, plus optional chat, WebRTC calling, AI voice control, and push notifications. It has no opinion about your frontend: build your own and point it here.
Relay Server only talks to your XP Series processor through the companion RelayServer driver (formerly named WebPanelBridge) - you need that driver installed and running on the processor first. It's listed in the Drivers catalog; find "RelayServer" there for the download and licensing details. Without it, this server has nothing to bridge to.
Relay Server sits between your XP Series processor (running the RelayServer driver) and everything else. Any number of driver connections and any number of client connections can be open at once:
feedback message from a driver (a light turning on, a sensor changing) is broadcast to every connected client.Access-Control-Allow-Origin: *, so your frontend doesn't need to be hosted on the same server, same machine, or even same network as this one - build a web app, a native app, or a script, and point it at your Relay Server's URL.Your custom frontend doesn't have to live only in a separate browser tab or app - Integration Designer's Web Object can embed any URL directly inside an RTI touchpanel/remote UI page. Point a Web Object at your own frontend (built against this Relay Server's API) to show it as a native-feeling part of the panel's own interface, alongside your regular RTI pages, instead of switching to a separate app.
Plain Node.js, no native modules to compile - runs the same way on macOS, Windows, and Linux. Requires Node.js 22.5 or newer (it uses Node's built-in node:sqlite module for chat history).
1. Download and unzip. 2. Copy/rename settings.example.json to settings.json and edit it in a text editor - it must be named exactly settings.json, the .example file itself is never read. 3. Run:
npm install npm start
Prefer environment variables instead of a settings file? They still work as a fallback (only used when settings.json doesn't exist):
npm install XP8_TOKEN=<random-string> CLIENT_TOKEN=<a-different-random-string> ADMIN_PASSWORD=<pick-one> npm start
settings.json key | Env var | Required | Meaning |
|---|---|---|---|
| xp8Token | XP8_TOKEN | Required | Shared secret the XP Series driver authenticates with on /xp8. |
| clientToken | CLIENT_TOKEN | Required | Shared secret browsers/apps authenticate with on /client. Must differ from xp8Token. |
| adminPassword | ADMIN_PASSWORD | Required | Protects config writes and admin endpoints (HTTP Basic Auth, username ignored). |
| port | PORT | Optional | Defaults to 8181. |
| webappDir | WEBAPP_DIR | Optional | Serve a built frontend's static files from here. Unset = no bundled frontend; every API route and both WebSocket endpoints still work. |
| turnHost | TURN_HOST | Optional | Adds a TURN server to the ICE-servers response (with turnSecret/TURN_SECRET). Without it, only public STUN is returned - fine on most networks, calls across strict NATs may fail to connect. |
Plain HTTP/WS only - no built-in TLS. Browsers require a secure context (HTTPS) for camera/microphone access, so put a reverse proxy (Caddy, nginx, etc.) in front for anything beyond same-machine testing. The XP Series driver's own connection has no such requirement and works fine over plain ws://.
/xp8 - the driver sideThe RelayServer driver connects here first:
→ driver sends {"type":"hello","token":"<XP8_TOKEN>","panelId":"xp8-main"} ← server replies {"type":"hello_ok"} // or {"type":"hello_failed","reason":"..."}
| Direction | Message | Meaning |
|---|---|---|
| driver → server | {"type":"feedback","key":"...","value":"..."} | Push a state update out to every connected client. |
| driver → server | {"type":"ping"} | Keepalive; server replies {"type":"pong"}. |
| server → driver | {"type":"button","slot":N} | A client pressed button slot N. |
| server → driver | {"type":"slider","slot":N,"value":V} | A client set slider slot N to V (0-100). |
| server → driver | {"type":"runmacro","name":"..."} | A client asked to run a named macro directly. |
| server → driver | {"type":"clients","count":N} | How many clients are currently connected. |
/client - the browser/app side (this is what your frontend uses)→ client sends {"type":"auth","token":"<CLIENT_TOKEN>"} ← server replies {"type":"auth_ok","feedback":{...current state...},"xp8Connected":true}
| Direction | Message | Meaning |
|---|---|---|
| client → server | {"type":"button","slot":N} | Press button slot N. |
| client → server | {"type":"slider","slot":N,"value":V} | Set slider slot N (0-100). |
| client → server | {"type":"runmacro","name":"..."} | Run a named macro. |
| server → client | {"type":"feedback","key":"...","value":"..."} | A state update pushed from a driver. |
| server → client | {"type":"xp8status","connected":true|false} | At least one XP Series driver is/isn't connected. |
Feedback (a light that's on, a sensor value, anything state-related) takes two hops: XP Series driver → Relay Server → your client. Each hop uses a different mechanism.
Step 1 below only works if the RelayServer driver is installed and running on your XP Series processor - see the Drivers catalog. Relay Server has nothing to relay without it.
The RelayServer driver exports a function in the "Feedback to Web App" category:
Push Feedback(Key, Value)
Both parameters are plain strings. Bind a call to this into whatever macro/event already fires when the state you care about changes - the same macro that already updates a touchpanel's feedback is usually the right place. Key is any identifier you choose (it isn't predefined anywhere); Value is whatever string representation makes sense for that key ("true"/"false" for a toggle, a number-as-string for a level).
// Example macro step:
Push Feedback("LivingRoomLight", "true")
/clientOnce connected and authed, every Push Feedback call arrives as a live message, and a client that just connected gets the current value of every key ever pushed so far in auth_ok's snapshot:
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "auth_ok") {
// msg.feedback = { "LivingRoomLight": "true", ... } - snapshot of every
// key pushed so far, so a client connecting late still sees current state
console.log(msg.feedback);
} else if (msg.type === "feedback") {
// fires every time the driver calls Push Feedback from now on
console.log(msg.key, "=", msg.value);
}
};
There's nothing to register or declare on the relay-server side - it just relays whatever key/value pairs the driver sends, and remembers the latest value per key so new connections start with current state instead of nothing.
This connects over a real WebSocket to our actual, running Relay Server - the exact same auth / button / slider / feedback messages documented above, aimed at a small sandboxed fake device (DemoLight, DemoVolume) instead of a real XP Series processor, so it's safe to leave connected. Open this page in two tabs and toggle the light in one - the other updates live via the same feedback broadcast a real driver would trigger.
| GET /api/config | Returns config.yaml's raw text. Open read. |
| PUT /api/config | Replaces it (validated as YAML first). Admin-protected. |
| POST /api/model | Multipart file - uploads a .glb for an optional 3D view. Admin-protected. |
| GET /api/webrtc/ice-servers | STUN (+ TURN if configured). |
| Socket.IO @ /api/comm/socket | register, chat_message, signal (offer/answer/ice), join_group_call / leave_group_call, push_call. |
| GET /api/comm/history?limit=100 | Chat history (sqlite-backed). |
| POST /api/comm/voice-message | Multipart audio + fields - upload a voice note. |
| POST /api/comm/media | Multipart file - image/video/file sharing. |
| GET /api/push/vapid-public-key | Public key for the browser's Push subscription call. |
| POST /api/push/subscribe | {deviceId, endpoint, keys} |
| DELETE /api/push/subscribe | {deviceId} |
| GET/PUT /api/admin/ai-config | Set OpenAI + Anthropic API keys, stored server-side only, never sent to a browser. Admin-protected. |
| POST /api/ai/voice | Multipart audio - transcribes via Whisper, asks Claude to translate the request into button/slider actions, dispatches them exactly like a real client press. Costs real money against your own OpenAI/Anthropic accounts. |
Works from a browser or from Node.js (with the ws package). Replace RELAY_URL and CLIENT_TOKEN with your own.
const RELAY_URL = "wss://your-server.com:8443";
const socket = new WebSocket(RELAY_URL + "/client");
socket.onopen = () => {
socket.send(JSON.stringify({ type: "auth", token: "<CLIENT_TOKEN>" }));
};
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "auth_ok":
console.log("Connected. XP Series processor online:", msg.xp8Connected);
console.log("Current feedback state:", msg.feedback);
break;
case "feedback":
// e.g. { key: "LivingRoomLight", value: "true" }
console.log(msg.key, "changed to", msg.value);
break;
case "xp8status":
console.log("XP Series connection is now:", msg.connected);
break;
}
};
function pressButton(slot) {
socket.send(JSON.stringify({ type: "button", slot }));
}
function setSlider(slot, value) {
// value is 0-100
socket.send(JSON.stringify({ type: "slider", slot, value }));
}
// e.g. turn on "Living Room Light" (bound to button slot 1)
pressButton(1);
// e.g. set "Music Volume" (bound to slider slot 1) to 60%
setSlider(1, 60);
const res = await fetch(RELAY_URL.replace("wss:", "https:") + "/api/config");
const yamlText = await res.text();
console.log(yamlText);