---
title: Connecting to Semurg over a WebSocket: live pushes and the same operations
slug: connect-over-websocket
date: 2026-10-12
author: Semurg
section: how-to
tags: [connections, websocket, realtime, how-to]
summary: One socket gives you the node's state pushed every second and every data operation the REST routes offer, with the same answers. How to connect, authenticate safely and send your first events.
status: published
covers: [route:POST /v1/query, route:GET /v1/cluster/util]
---
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.

> Diagram one-socket: 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.

## 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.

> Diagram token-on-the-socket: 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.

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 runnable kind=sdk:node needs=token
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);
```

```expect
expect: join ok, remember ok, recall found Acme, snapshot seen
assert: out~^join ok$;out~^remember ok$;out~^recall found Acme$;out~^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

- The [REST post](/blog/connect-over-rest): the same operations as plain HTTP.
- [Look after your node](/blog/look-after-your-node): the cluster routes behind each topic.
