Docs menu
Reference

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 a type.
  • Requests from the client carry an id (any non-empty string). Core answers each request with an ack or an error that has the same id.
  • The client sends hello first. Core answers welcome with a full snapshot of every device and timeline.
  • After that, the client subscribes to topics and receives event messages, 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_..." }
FieldRequiredDescription
vyesProtocol version, 1.
idyesRequest id.
typeyes"hello".
clientyesClient name and version, for example tangle-interact/0.1.0. Shown in logs and the audit log.
tokennoAn 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 the read and control scopes, like a local dashboard.
  • Other machines: must send a token. Without one, core answers error with code unauthorized and 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 (no id) 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": []
  }
}
FieldDescription
nodeIdName of this core node (--node-id, default core-1).
rolestandalone, primary or backup.
contractVersionContract version of this core.
clusterRedundancy status (contract ClusterStatus). See Redundancy.
licenseContract 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

typeScopeFieldsack result
subscribereadtopics: one or more topics{ "topics": [...] }, the full list now subscribed
unsubscribenonetopics: one or more topics{ "topics": [...] }, the list left
intentcontrolintent: an IntentRequestIntentStatus (first status)
timelinecontrolcontrol: a TimelineControlTimelineState
automation.runcontrolautomationId, force, params (optional)AutomationRun
variable.setcontrolname, valueThe variable's new state
control.setcontrolcontrolId, and value, add, label, visible, enabled or propsThe control's state
control.eventcontrolcontrolId, event (press, release, click, hold), value (optional){ "published": true }
positions.pushcontrolpositions: 1 to 1000 positions, in map metres{ accepted, rejected }
snapshotreadnonenone: core answers with a snapshot message instead of ack
sentinel.statusreadnoneSentinel overview
sentinel.alertsreadhistory (optional boolean){ alerts, history }
sentinel.ackcontrolalertId, note (optional)The alert
sentinel.silencecontrolalertId or ruleId, until{ until, alerts, ruleId }
sentinel.metricscontrolsamples: 1 to 1000 MetricSample{ "accepted": n }
license.statusreadnoneLicenseInfo
license.resumecontrolnoneLicenseInfo

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 fieldRequiredDescription
opyesplay, pause, stop, seek or go (next cue of a cue list).
timelineIdyesThe timeline.
positionMsfor seekPosition 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

typeWhenFields
welcomeAnswer to helloSee welcome.
ackA request succeededid, result
errorA request failed, or the session endsid (when it answers a request), code, message
snapshotAnswer to snapshot, or after the client fell behindid (when it answers a request), snapshot
eventSomething you subscribed to changedtopic, 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).

TopicEvent fieldsWhen
device.state:<deviceId>deviceId, capability, values, atA device's state changed. values holds the changed keys of that capability.
device.status:<deviceId>deviceId, status, detail, atA device's status changed (connecting, online, degraded, offline, timeout, error).
device.event:<deviceId>deviceId, name, data, atA 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), atAn intent's status changed.
timeline.state:<timelineId>state (TimelineState), atA timeline's transport state changed.
project.changed:<projectId>projectId, revision, changes, atThe 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, atA module process started (running), crashed (restarting), crashes too often (unstable) or stopped (stopped).
module.health:<moduleId>moduleId, cpuPercent, memoryBytes, avgLatencyMs, actions, warning, atEvery 10 s while a module runs. warning is set when its average action latency is above 500 ms.
automation.run:<automationId>run (AutomationRun), atAn 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 entryA device log entry was added.
variable.changed:<name>name, value, previous, by, atA project variable changed.
control.changed:<controlId>id, state, previous, by, atA dashboard control's value or properties changed.
control.event:<controlId>id, event, value, by, atSomeone pressed, released, clicked or held a control.
listener.message:<listenerId>the log entry: message, matched rule, captures, runs, replyA listener received a message.
listener.status:<listenerId>listener, atA listener opened, closed, failed or its client count changed.
dashboard.draft:<dashboardId>id, by, atA dashboard draft was saved (builders and previews elsewhere refresh).
cluster.placement:coreplacement, reason, atShared mode: the primary placed devices again (start, node down or back, rebalance).
project.switching:<projectId>projectId, nextProjectId, atThe open project is about to close (another opens, or none). Clients disconnect for a moment and reload.
sentinel.alert:<ruleId>transition, alert, channelId, detail, atAn alert changed (pending, firing, resolved, dropped, repeat, acknowledged, silenced), or a notification was sent (notified) or failed (notifyFailed).
license.status:corelicense (LicenseInfo), atThe 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" }
CodeMeaning
hello_requiredThe first message was not hello. The socket closes.
unsupported_versionv is not 1. The socket closes.
invalid_messageNot JSON, missing or unknown fields, a bad topic, a second hello, or an invalid intent or timeline control (the message lists each problem).
unknown_typeUnknown type.
unauthorizedNo token from another machine, unknown or revoked token, expired dashboard session.
missing_scopeThe token lacks the scope this message needs.
not_licensedMonitoring is not part of this edition.
not_primaryThis node is a redundancy backup: connect to the primary to control.
timeline_not_found, automation_not_found, alert_not_found, alertRule_not_foundUnknown id.
unsupportedThe timeline does not support this operation.
disabledThe automation is disabled.
not_pausedlicense.resume when nothing is paused.
unavailableA 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).