Blog / How-to guides

Connecting to Semurg over a WebSocket: live pushes and the same operations

12 October 2026 · Semurg · 4 min read · Markdown

Verified: every command below ran on a fresh node of r11.78.20261012.bc0d94f (how)

connectionswebsocketrealtimehow-to

REST answers when you ask. A WebSocket also tells you when something changes. Semurg's socket does both: it pushes the node's state every second, and it accepts the same operations as the REST routes, answered by the same code, so a dashboard and an application can share one connection.

One socket joins a topic with the token inside the join message; the node pushes that topic's state every second, and the same socket sends data operations as events and gets replies with the same bodies the REST routes return.Your dashboard or appJoin a topic, token inthe payloadOne socketA snapshot everysecondOperations sent aseventsThe same code as theREST routesA reply on the sametopic

The socket and its topics

The socket lives at /socket on your node and speaks the Phoenix channels protocol: every message is a JSON array of a join reference, a message reference, a topic, an event and a payload. You join a topic, and then send events on it.

The topics push the node's state every second, the same maps the REST cluster routes return:

  • cluster:util: the utilisation snapshot (disks, cores, memory bandwidth, per node and in total), with the memory-tier counters beside it.
  • cluster:conveyor: the memory tier, prefetch and janitor counters alone.
  • cluster:nodes: the nodes this node knows.
  • cluster:licence: the licence state.

The token, safely

From the machine the node runs on, the token can ride in the connect address. From anywhere else it must not: an address lands in every proxy and access log on its way in, so off the local machine a token in the address is ignored, and the socket connects unproven. Send the token in the join message instead, inside the payload, where no log sees it. A socket without a valid token is treated exactly as a REST call without one: it sees only the coarse public view, and on a node exposed without a token, the public sandbox.

From the node's own machine the token may ride in the connect address; from anywhere else a token in the address is ignored, because addresses land in proxy logs, so it travels in the join message where no log sees it.Connecting from thenode's machineConnecting fromanywhere elseToken in the connectaddressIgnored: addressesland in logsToken in the joinmessageThe socket is proven

If the token is revoked while the socket is open, the channel re-checks it on its next message or push and closes with a revoked event, so a revoked key does not keep a live connection.

The same operations, as events

A joined socket can send any data operation as an event, answered as a reply on the same topic with the same body the REST route would return. Among them: memory:recall and the other memory calls, object:put, object:get, stream:produce, stream:read, search:term_postings, geo:within, time_series:latest, vector:hybrid, neighbours:read, rows:read and analytics:run. Two events push to you on their own: stream:wake when a stream you follow has new messages, and jobs:done when a job you subscribed to finishes.

A first connection

This example runs with Node.js and no packages, and we run it as written on a fresh node. It joins cluster:util with the token in the join message, stores that Dana works at Acme, asks memory what it holds about Dana, and prints one line per step:

javascript
const token = process.env.SEMURG_API_TOKEN;
const ws = new WebSocket("ws://localhost:4000/socket/websocket?vsn=2.0.0");
let ref = 0;
const pending = new Map();
const send = (topic, event, payload, step) => {
  const r = String(++ref);
  pending.set(r, step);
  ws.send(JSON.stringify(["1", r, topic, event, payload]));
};

ws.onopen = () => {
  send("cluster:util", "phx_join", { token }, "join");
  send("cluster:util", "memory:remember", {
    scope: "demo",
    facts: {
      entities: [{ name: "Dana", type: "Person" }, { name: "Acme", type: "Company" }],
      relations: [{ source: "Dana", target: "Acme", type: "WORKS_AT" }],
    },
  }, "remember");
  send("cluster:util", "memory:recall", { scope: "demo", subject: "Dana" }, "recall");
};

let snapshotSeen = false;
ws.onmessage = (message) => {
  const [, r, , event, payload] = JSON.parse(message.data);
  if (event === "phx_reply" && pending.has(r)) {
    const step = pending.get(r);
    pending.delete(r);
    if (step === "recall") {
      const found = JSON.stringify(payload.response).includes("Acme");
      console.log(found ? "recall found Acme" : "recall found nothing");
    } else {
      console.log(`${step} ${payload.status}`);
    }
  } else if (event === "snapshot" && !snapshotSeen) {
    snapshotSeen = true;
    console.log("snapshot seen");
  }
};

// stop after a few seconds; a long-lived client keeps the socket open instead
setTimeout(() => ws.close(), 5000);

Expected: join ok, remember ok, recall found Acme, snapshot seen

Each reply comes back on the same topic as a phx_reply carrying the reference of the message it answers, with status ok or error; the recall's reply carries the same state and items the /v1/memory/recall route returns. Between replies, a snapshot event arrives every second; this example prints the first one and closes the socket after a few seconds.

When something goes wrong

A refused operation replies with status error and the same error and detail the REST route gives for the same mistake. A join with no valid token, on a node that requires one, sees only the public view, never your data. A push the node cannot fill sends null for that field rather than dropping the connection.

Next

How these commands were checked

Our test farm ran each of the 1 command block marked runnable, exactly as written, with the answer checked against the expectation printed under it, against a fresh node of the published release r11.78.20261012.bc0d94f. A post is published only when every one of them passed there.

Related