WebSocket
The live client protocol at /ws: hello and authentication, subscriptions, events, intents, timeline control, automations, sentinel and errors.
tangle-core serves a WebSocket at ws://<host>:7340/ws. Dashboards use it, and so can any tool that needs live state with low latency. It is for live control: to change the project, use the REST API or MCP.
Overview
- Every message is one JSON object in a text frame.
- Every message carries
"v": 1(the protocol version) and atype. - Requests from the client carry an
id(any non-empty string). Core answers each request with anackor anerrorthat has the sameid. - The client sends
hellofirst. Core answerswelcomewith a full snapshot of every device and timeline. - After that, the client subscribes to topics and receives
eventmessages, sends intents, drives timelines, and asks for snapshots.
client core
| hello -------------------------> |
| <-------------------- welcome | (snapshot)
| subscribe ----------------------> |
| <------------------------ ack |
| <---------------------- event | (as things change)
| intent -------------------------> |
| <------------------------ ack | (IntentStatus)
The message types are defined in the contract (ClientMessage, ServerMessage), so clients can validate them.
Connect and authenticate
Send hello within 5 seconds of opening the socket, or core closes it.
{ "v": 1, "id": "1", "type": "hello", "client": "my-tool/1.0.0", "token": "tgl_..." }
| Field | Required | Description |
|---|---|---|
v | yes | Protocol version, 1. |
id | yes | Request id. |
type | yes | "hello". |
client | yes | Client name and version, for example tangle-interact/0.1.0. Shown in logs and the audit log. |
token | no | An API token (tgl_...) or a dashboard session token (tgd_...). |
Who may connect:
- This machine, no token: clients connecting from the same machine (loopback) may leave out
token. They get thereadandcontrolscopes, like a local dashboard. - Other machines: must send a token. Without one, core answers
errorwith codeunauthorizedand closes the socket. - With a token: the client gets that token's scopes. Core checks the token again every 2 seconds; when it is revoked or a dashboard session expires, core sends an
error(noid) and closes the socket.
Create tokens with tangle-core token create (see CLI). Dashboards opened in a browser get a session token from POST /api/v1/dashboards/{id}/session (see REST API). Scopes are described in REST API.
If the first message is not a valid hello, core answers error and closes the socket: hello_required (another type), unsupported_version (v is not 1) or invalid_message.
welcome
{
"v": 1,
"id": "1",
"type": "welcome",
"nodeId": "core-a",
"role": "primary",
"contractVersion": "0.2.43",
"cluster": { "role": "primary", "nodes": [] },
"license": { "mode": "licensed", "status": { }, "features": { } },
"snapshot": {
"devices": [
{
"device": { "id": "proj-1", "name": "Projector", "moduleId": "misc.pjlink.projector" },
"status": "online",
"state": { "tangle.power": { "on": true } }
}
],
"timelines": []
}
}
| Field | Description |
|---|---|
nodeId | Name of this core node (--node-id, default core-1). |
role | standalone, primary or backup. |
contractVersion | Contract version of this core. |
cluster | Redundancy status (contract ClusterStatus). See Redundancy. |
license | Contract LicenseInfo (mode, status, features, session). Present when licensing is active. |
snapshot | { devices: [DeviceSnapshot], timelines: [TimelineState] }. |
Each DeviceSnapshot has device (the project's device), status (connecting, online, degraded, offline, timeout or error) and state (capability id, then state key, then value).
Client messages
type | Scope | Fields | ack result |
|---|---|---|---|
subscribe | read | topics: one or more topics | { "topics": [...] }, the full list now subscribed |
unsubscribe | none | topics: one or more topics | { "topics": [...] }, the list left |
intent | control | intent: an IntentRequest | IntentStatus (first status) |
timeline | control | control: a TimelineControl | TimelineState |
automation.run | control | automationId, force, params (optional) | AutomationRun |
variable.set | control | name, value | The variable's new state |
control.set | control | controlId, and value, add, label, visible, enabled or props | The control's state |
control.event | control | controlId, event (press, release, click, hold), value (optional) | { "published": true } |
positions.push | control | positions: 1 to 1000 positions, in map metres | { accepted, rejected } |
snapshot | read | none | none: core answers with a snapshot message instead of ack |
sentinel.status | read | none | Sentinel overview |
sentinel.alerts | read | history (optional boolean) | { alerts, history } |
sentinel.ack | control | alertId, note (optional) | The alert |
sentinel.silence | control | alertId or ruleId, until | { until, alerts, ruleId } |
sentinel.metrics | control | samples: 1 to 1000 MetricSample | { "accepted": n } |
license.status | read | none | LicenseInfo |
license.resume | control | none | LicenseInfo |
Every client message needs v, id and type. Unknown fields are refused (invalid_message). Sending hello again gets invalid_message.
subscribe and unsubscribe
{ "v": 1, "id": "2", "type": "subscribe", "topics": ["device.state:*", "timeline.state:main-show"] }
A topic is <event type>:<subject id> or <event type>:* (every subject). Subscriptions add up; unsubscribe removes the exact topics given.
intent
Makes something happen on devices. The intent object is the same as the REST body (see REST API intents).
{
"v": 1,
"id": "3",
"type": "intent",
"intent": { "name": "scene.recall", "target": { "kind": "room", "id": "room-a" }, "params": { "scene": "presentation" } }
}
The ack carries the first IntentStatus (usually accepted, or scheduled or rejected). Subscribe to intent.status:*, or intent.status:<intentId>, to follow it to completed, partial or failed. Intents sent over the WebSocket are recorded with source dashboard.
timeline
{ "v": 1, "id": "4", "type": "timeline", "control": { "op": "play", "timelineId": "main-show" } }
control field | Required | Description |
|---|---|---|
op | yes | play, pause, stop, seek or go (next cue of a cue list). |
timelineId | yes | The timeline. |
positionMs | for seek | Position from the start, in ms. |
The ack carries the new TimelineState. See Timelines.
automation.run
{ "v": 1, "id": "7", "type": "automation.run", "automationId": "close-room", "force": false }
Runs an automation now. Its conditions are checked unless force is true. The ack carries the run: { runId, automationId, state, trigger, reason, startedAt }, with state started, or skipped and a reason. A disabled automation gets error with code disabled; on a backup node, not_primary. See Automations.
snapshot
{ "v": 1, "id": "5", "type": "snapshot" }
Core answers with a snapshot message (same id) holding the full current state.
Sentinel messages
{ "v": 1, "id": "9", "type": "sentinel.ack", "alertId": "offline/device:proj-1", "note": "on my way" }
{ "v": 1, "id": "10", "type": "sentinel.metrics", "samples": [{ "name": "room.temperature", "value": 22.5, "labels": { "room": "lobby" } }] }
until is a timestamp in ms since the Unix epoch and must be in the future. sentinel.alerts returns up to 100 history entries. These work as on the REST API; monitoring is part of Tangle Pro and Ultimate. See Sentinel.
License messages
{ "v": 1, "id": "r1", "type": "license.resume" }
In the free designer and demo modes, device connections stop and playing timelines pause every hour. license.resume resumes them for another hour. See Licensing.
Server messages
type | When | Fields |
|---|---|---|
welcome | Answer to hello | See welcome. |
ack | A request succeeded | id, result |
error | A request failed, or the session ends | id (when it answers a request), code, message |
snapshot | Answer to snapshot, or after the client fell behind | id (when it answers a request), snapshot |
event | Something you subscribed to changed | topic, event |
{ "v": 1, "id": "3", "type": "ack", "result": { "intentId": "8c0e...", "state": "accepted", "updatedAt": 1760000000000 } }
A client that reads too slowly to keep up with events gets a snapshot message (without id) holding the current state, in place of the events it missed.
Events
{
"v": 1,
"type": "event",
"topic": "device.state:proj-1",
"event": { "type": "device.state", "deviceId": "proj-1", "capability": "tangle.power", "values": { "on": false }, "at": 1760000000000 }
}
event.at is a timestamp in ms. The same topics and event bodies are available as Server-Sent Events at GET /api/v1/events (see REST API).
| Topic | Event fields | When |
|---|---|---|
device.state:<deviceId> | deviceId, capability, values, at | A device's state changed. values holds the changed keys of that capability. |
device.status:<deviceId> | deviceId, status, detail, at | A device's status changed (connecting, online, degraded, offline, timeout, error). |
device.event:<deviceId> | deviceId, name, data, at | A free-form device event, such as a button press. |
cluster.discovered:* | the discovered core (contract DiscoveredCore) | A core not in this cluster appeared on the network. Sent by a primary or standalone core, once per core. |
intent.status:<intentId> | status (IntentStatus), at | An intent's status changed. |
timeline.state:<timelineId> | state (TimelineState), at | A timeline's transport state changed. |
project.changed:<projectId> | projectId, revision, changes, at | The project configuration changed (REST, MCP or a reload). revision increases by one per change; changes lists { op, kind, id, parent }. |
module.status:<moduleId> | moduleId, status, restarts, retryInMs, detail, at | A module process started (running), crashed (restarting), crashes too often (unstable) or stopped (stopped). |
module.health:<moduleId> | moduleId, cpuPercent, memoryBytes, avgLatencyMs, actions, warning, at | Every 10 s while a module runs. warning is set when its average action latency is above 500 ms. |
automation.run:<automationId> | run (AutomationRun), at | An automation run started, ended (completed, partial, failed, cancelled) or was skipped. |
device.action:<deviceId> | the action, its source and its outcome (sent, pending, progress, done, failed) | An action was sent to a device, or progressed (a projector warming up). |
device.log:<deviceId> | the log entry | A device log entry was added. |
variable.changed:<name> | name, value, previous, by, at | A project variable changed. |
control.changed:<controlId> | id, state, previous, by, at | A dashboard control's value or properties changed. |
control.event:<controlId> | id, event, value, by, at | Someone pressed, released, clicked or held a control. |
listener.message:<listenerId> | the log entry: message, matched rule, captures, runs, reply | A listener received a message. |
listener.status:<listenerId> | listener, at | A listener opened, closed, failed or its client count changed. |
dashboard.draft:<dashboardId> | id, by, at | A dashboard draft was saved (builders and previews elsewhere refresh). |
cluster.placement:core | placement, reason, at | Shared mode: the primary placed devices again (start, node down or back, rebalance). |
project.switching:<projectId> | projectId, nextProjectId, at | The open project is about to close (another opens, or none). Clients disconnect for a moment and reload. |
sentinel.alert:<ruleId> | transition, alert, channelId, detail, at | An alert changed (pending, firing, resolved, dropped, repeat, acknowledged, silenced), or a notification was sent (notified) or failed (notifyFailed). |
license.status:core | license (LicenseInfo), at | The license changed, or a designer or demo session paused or resumed. |
Topic subjects follow the id rules (lowercase letters, digits, ., _, -) or are a UUID (intents). Examples: device.status:*, timeline.state:main-show, automation.run:*, license.status:core.
Errors
{ "v": 1, "id": "4", "type": "error", "code": "missing_scope", "message": "this token lacks the `control` scope" }
| Code | Meaning |
|---|---|
hello_required | The first message was not hello. The socket closes. |
unsupported_version | v is not 1. The socket closes. |
invalid_message | Not JSON, missing or unknown fields, a bad topic, a second hello, or an invalid intent or timeline control (the message lists each problem). |
unknown_type | Unknown type. |
unauthorized | No token from another machine, unknown or revoked token, expired dashboard session. |
missing_scope | The token lacks the scope this message needs. |
not_licensed | Monitoring is not part of this edition. |
not_primary | This node is a redundancy backup: connect to the primary to control. |
timeline_not_found, automation_not_found, alert_not_found, alertRule_not_found | Unknown id. |
unsupported | The timeline does not support this operation. |
disabled | The automation is disabled. |
not_paused | license.resume when nothing is paused. |
unavailable | A service is not ready. |
The codes are the same as the REST API's (see REST API errors), except that validation problems use invalid_message.
Example: a minimal client
const ws = new WebSocket("ws://127.0.0.1:7340/ws");
let next = 1;
const send = (msg) => ws.send(JSON.stringify({ v: 1, id: String(next++), ...msg }));
ws.onopen = () => send({ type: "hello", client: "example/1.0.0" });
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "welcome") {
console.log("devices:", msg.snapshot.devices.map((d) => `${d.device.id} ${d.status}`));
send({ type: "subscribe", topics: ["device.status:*", "timeline.state:*"] });
send({ type: "intent", intent: { name: "power.on", target: { kind: "room", id: "room-a" } } });
} else if (msg.type === "event") {
console.log(msg.topic, msg.event);
} else if (msg.type === "error") {
console.error(msg.code, msg.message);
}
};
Run on the same machine as tangle-core, this needs no token. From another machine, add token to the hello and start tangle-core with remote access (see CLI).