WebSocket API

Metric Broker WebSocket

The bytes and json encodings, addresses, every op and op code, frames byte by byte, acknowledgements, fan-out, errors and close codes.

The Metric Broker has two endpoints with the same operations. Pick one per connection:

EncodingURLFrames
byteswss://api.thuum.dev/api/{domain}/metric/connect/bytes?token=…binary only
jsonwss://api.thuum.dev/api/{domain}/metric/connect/json?token=…text only

The handshake, token, claim snapshot and keepalive are as on every broker (WebSocket protocol): an invalid or expired token is refused with 401, a key without a Metric Broker claim with 403. A frame of the wrong type (text on bytes, binary on json) closes the connection with 1003.

Both encodings interoperate without loss: a measurement published over bytes reaches a json subscriber as plain JSON values (see Values in JSON), and the other way round.

Messages#

A message holds one or more ops, in both directions: at most 1000 per message.

  • bytes: count:u16, then count ops back to back. Each op starts with its op code; its length follows from the code and the dtype in its Tag. Everything is little-endian.
  • json: an array of op objects. An op on one series carries its address ("000C:3E01"), an op on a whole Group its group ("000C"), both hex strings (below); x and y are plain JSON values typed by the Tag's dtype (below); ack is an optional boolean. No base64 anywhere.

Replies to one message come back as one message, in op order. A message whose ops all succeed without ack gets no reply at all.

Two formats, one broker#

bytes and json are two formats of the same protocol, handled the same way:

  • One frame type per connection. The WebSocket standard marks each frame as text or binary. The Metric Broker ties that type to the connection: a /connect/bytes connection sends and accepts binary frames only, a /connect/json connection text frames only. So the meas messages the server pushes have one unambiguous format: the one the subscriber chose. Control frames (ping, pong, close) are unaffected. A data frame of the other type closes the connection with 1003.
  • Decoded at the edge. As a frame arrives, its format's decoder turns it into one internal form of the ops. One core then does everything else (claims, storage, live delivery, subscriptions, acks, the audit log), knowing neither format. Its replies and deliveries are encoded for each connection in that connection's own format, so a bytes publisher reaches a json subscriber, and the other way round, without loss.
  • An :error reply echoes exactly what was sent. In bytes, the op's original bytes, then the error code: an f32 NaN sent as 00 00 C0 7F comes back as 00 00 C0 7F (example). In JSON, the op's own fields as written (address, x and y for a pub; address for sub and unsub; group for the group ops), even when they are what's wrong.
  • One order of checks, both formats: the op's structure (the decoder), then the Tag's dtype, then the claim, then the values. The first that fails answers. A malformed value sent to a Group you may not publish to is forbidden in both formats.

What "structure" means, and what each format does when it is wrong, is the one difference: a malformed bytes message can't be split into ops, so it closes the connection (1007, or 1009 for too many ops); a JSON op with a bad ack, address or group is answered with its own :error (bad_request), and a message that isn't an array of ops with an error op. The connection stays open.

Addresses in JSON#

In JSON, Groups and Tags are hex text, the way the concepts describe:

FieldFormExampleIn
addressGGGG:TTTT (4 + 4 hex digits)"000C:3E01"pub, sub, unsub, their replies, meas
groupGGGG (4 hex digits)"000C"sub-group, unsub-group and their replies
  • Exactly 4 digits per part, leading zeros kept, so a Tag's first two digits are its dtype: "000C:3E01" is Group 000C (12), Tag 3E01, sns_f32 id 1.
  • Read in either case ("000c:3e01"), always written uppercase in replies and meas. An :error reply repeats your fields as you wrote them.
  • No 0x, no decimal, no JSON numbers. Anything else answers the op's :error with bad_request:
Sentmessage
"address": "12:3E01", "C:3E01"address must be an address: 4 + 4 hex digits, e.g. "000C:3E01"
"address": "0x000C:0x3E01", 12address must be an address: 4 + 4 hex digits, e.g. "000C:3E01"
"group": "12", "0x0C", 12group must be a Group: 4 hex digits, e.g. "000C"

The bytes encoding doesn't change: Group and Tag are two little-endian u16, and "000C:3E01" travels as 0C 00 01 3E.

Values in JSON#

In the json encoding (and over REST, which uses the same rules), x and y are written the way people read them. Numbers are JSON numbers, except 64-bit integers and times, which are strings:

ValueIn JSONExamples
y u8 u16 u32 i8 i16 i32, discrete d8 d16 d32a JSON integer, in the type's range200, -3, 4294967295
y f16 f32 f64; x f16 f32a JSON number (an integer is fine)23.5, 0.1, -2
y u64 i64 d64a decimal string, in the type's range, always"18446744073709551615", "-42", "0"
a time x (cts; server times in pub:ok and meas)an ISO 8601 string, Z or an offset, up to 9 decimals"2026-11-02T10:00:00.5Z", "2026-11-02T11:00:00+01:00"
  • 64-bit integers are always strings, whatever their value ("0", not 0), so a client never has to check which form it got. Digits only, with an optional leading - (leading zeros are accepted); +, spaces, decimals, exponents and hex are a bad_request.
  • f16 and f32 are rounded to the nearest value of their type (ties to even). The replies (pub:ok, meas) carry the value as stored: f32 0.1 comes back as 0.10000000149011612, f16 0.1 as 0.0999755859375, f16 65519 as 65504. A value that rounds beyond the type's finite range is a bad_request (f16 65520, f32 3.5e38); one too small rounds to 0 (f16 1e-10).
  • Times go from 1970-01-01T00:00:00Z to 2262-04-11T23:47:16.854775807Z (0 to 2^63 − 1 ns). Write them as YYYY-MM-DDTHH:MM:SS, with up to 9 decimals and Z or an offset ±HH:MM (uppercase T and Z). Replies always use UTC with Z, trailing zeros of the fraction dropped: "2026-11-02T11:00:00.500+01:00" comes back as "2026-11-02T10:00:00.5Z".
  • A value of the wrong JSON type is a bad_request: a string for a u8, a fraction for an i32, a number for a u64 or a time, true for anything. Values are checked after the claim (order of checks), so a malformed value on a Group you may not publish to gets forbidden, as it would over bytes.

In JavaScript, the strings convert without loss:

JavaScript
const [meas] = JSON.parse(message.data); // { op: 'meas', address: '000C:3701', x, y }
const when = new Date(meas.x); // a time: milliseconds are enough for display
const counter = BigInt(meas.y); // a u64 y: "18446744073709551615" → 18446744073709551615n, exact

@thuum/client does all of this for you: toJsonValue and fromJsonValue (and parseTime / formatTime for full nanosecond times), or simply MetricBrokerSocket, which hands you numbers and bigints (With the client).

Operations#

Each client op has a no-ack code (the default) and an ack code.

OpNo-ack / ackBytes after the op codeJSON fieldsFlag
pub01 / 02group:2 tag:2 [x] yaddress, x?, y, ack?P
sub03 / 04group:2 tag:2address, ack?S
sub-group05 / 06group:2group, ack?S
unsub07 / 08group:2 tag:2address, ack?—
unsub-group09 / 0Agroup:2group, ack?—

The server sends:

OpCode(s)Bytes after the op codeJSON fields
pub:ok11group:2 tag:2 x yaddress, x, y
pub:error12group:2 tag:2 [x] y code:2address, x?, y, code, message
sub:ok / sub:error13 / 14group:2 tag:2 (+ code:2)address (+ code, message)
sub-group:ok / sub-group:error15 / 16group:2 (+ code:2)group (+ code, message)
unsub:ok / unsub:error17 / 18group:2 tag:2 (+ code:2)address (+ code, message)
unsub-group:ok / unsub-group:error19 / 1Agroup:2 (+ code:2)group (+ code, message)
meas21group:2 tag:2 x yaddress, x, y
  • [x] is present when the client sets x (x digit 4–6) and absent with server time (0–3): a pub with server time carries no x (in JSON, an x is a bad_request).
  • In pub:ok and meas, x is always present: with server time, the time the server set (8 bytes in bytes, an ISO 8601 string in JSON). pub:error repeats the pub as it was sent.
  • An :error reply echoes the op as you sent it: in bytes its bytes, in JSON its own fields (address, x, y for a pub; address for sub and unsub; group for the group ops), even when they are what's wrong ("group": "12" gets a sub-group:error with "group": "12"), so you can match it to the op.
  • The sizes of x and y come from the Tag: see Datatypes. In bytes, an op's length is 1 (code) + 2 (group) + 2 (tag, if any) + x + y (+ 2 for an error code).

Error codes#

NumberJSON codeMeaning
1forbiddenThe connection's claim lacks the flag on this Group (P to publish, S to subscribe). message: Missing P claim on Group 0063.
2bad_requestAn invalid dtype in the Tag, a missing or extra x, and: in bytes, a NaN or infinite float or a time above 2^63 − 1; in JSON, a malformed address, group or ack, or a value of the wrong JSON type, out of its type's range, or a malformed time.
3unknown_opJSON only: an op the broker doesn't know, or none (an error op). In bytes, an unknown op code closes the connection instead.
4limitReserved for limits on what a Domain may do; not sent today.
5internalThe measurement could not be stored (pub:error); nothing was stored. Safe to retry.

In bytes the code is a u16 (01 00 is forbidden) and there is no message. JSON replies use the name and add a human-readable message, for example:

Cause (JSON)message
"address": "12:3E01"address must be an address: 4 + 4 hex digits, e.g. "000C:3E01"
"group": 12group must be a Group: 4 hex digits, e.g. "000C"
"ack": "yes"ack must be a boolean
"address": "000C:7C01" (dtype 7C)Tag 7C01 has dtype 7C, which is not valid
A pub to Group 0063 without PMissing P claim on Group 0063
"y": "23.5" on an f32 Tagy must be a number (f32)
"y": 3.5e38 on an f32 Tagy must be a number within the f32 range
"y": 1.5 (or "7") on a u8 Tagy must be an integer (u8)
"y": 256 on a u8 Tagy must be from 0 to 255 (u8)
"y": 42 on a u64 Tagy must be a decimal string (u64)
"y": "18446744073709551616" on a u64 Tagy must be from 0 to 18446744073709551615 (u64)
"x": 1793613600000000000 on a cts Tagx must be an ISO 8601 time from 1970-01-01T00:00:00Z to 2262-04-11T23:47:16.854775807Z
An x on a server-time Tagx must be absent for sns_f32: the server sets it
No x on a client-time Tagx is required for cts_f64
No yy is required

The rows are in the order the checks run: structure, dtype, claim, values. Only the first failure is reported.

Frames, byte by byte#

All examples use Group 000C (12). Bytes are shown in wire order: Group and Tag are little-endian, so the address 000C:3E01 is written 0C 00 01 3E.

One op at a time#

Text
sns_f32  pub   (1 + 2 + 2 + 0 + 4 = 9 B)
  01                        op     pub
  0C 00                     group  000C (12)
  01 3E                     tag    3E01  (sns_f32, id 1)
                            x      none: the server sets it
  00 00 BC 41               y      23.5

sns_f32  meas  (1 + 2 + 2 + 8 + 4 = 17 B)
  21                        op     meas
  0C 00                     group  000C (12)
  01 3E                     tag    3E01  (sns_f32, id 1)
  00 A5 1C 12 11 32 E4 18   x      1793613600500000000 ns = 2026-11-02T10:00:00.5Z (server)
  00 00 BC 41               y      23.5

cts_f64  pub   (1 + 2 + 2 + 8 + 8 = 21 B)
  01                        op     pub
  0C 00                     group  000C (12)
  01 4F                     tag    4F01  (cts_f64, id 1)
  00 40 4F F4 10 32 E4 18   x      1793613600000000000 ns = 2026-11-02T10:00:00Z (client)
  00 00 00 00 00 80 37 40   y      23.5

f16_u8   pub   (1 + 2 + 2 + 2 + 1 = 8 B)
  01                        op     pub
  0C 00                     group  000C (12)
  07 54                     tag    5407  (f16_u8, id 7)
  00 3C                     x      1.0
  03                        y      3

Whole messages#

Two ops in one message, no acks (14 B). Nothing comes back unless an op fails:

Text
→ 02 00                         count      2
  01 0C 00 01 3E 00 00 BC 41    pub        sns_f32, y 23.5   (9 B)
  05 0C 00                      sub-group  000C              (3 B)

A subscription with an ack (5 B), and its reply (5 B):

Text
→ 01 00  06 0C 00               count 1, sub-group (ack), group 000C
← 01 00  15 0C 00               count 1, sub-group:ok,    group 000C

A publish with an ack (11 B). The reply (19 B) adds the time the server set:

Text
→ 01 00  02 0C 00 01 3E 00 00 BC 41
← 01 00  11 0C 00 01 3E 00 A5 1C 12 11 32 E4 18 00 00 BC 41
         └op┘└grp┘└tag┘└────────── x ─────────┘└─── y ───┘

The same publish without the P flag on Group 000C (the reply is always sent):

Text
← 01 00  12 0C 00 01 3E 00 00 BC 41 01 00
         pub:error, the pub as sent, code 1 (forbidden)

An error echoes the op#

A publish (ack) of an f32 NaN, with the P flag: the claim passes, the value doesn't, and the pub:error repeats the op's bytes exactly, NaN included, then code 2 (bad_request):

Text
→ 01 00  02 0C 00 01 3E 00 00 C0 7F
         pub (ack), 000C, 3E01, y NaN
← 01 00  12 0C 00 01 3E 00 00 C0 7F 02 00
         pub:error, the pub as sent (NaN included), code 2 (bad_request)

Without the P flag, the same frame gets code 1 (forbidden): the claim is checked before the value.

The same in JSON#

The same exchanges over json, with every kind of value (addresses in Group 000C; Tags 3E01 sns_f32, 4F01 cts_f64, 3701 sns_u64, 5D01 f16_f16):

JSON
→ [{ "op": "sub-group", "group": "000C", "ack": true }]
← [{ "op": "sub-group:ok", "group": "000C" }]

→ [{ "op": "pub", "address": "000C:3E01", "y": 23.5, "ack": true }]
← [{ "op": "pub:ok", "address": "000C:3E01", "x": "2026-11-02T10:00:00.5Z", "y": 23.5 }]
← [{ "op": "meas", "address": "000C:3E01", "x": "2026-11-02T10:00:00.5Z", "y": 23.5 }]

→ [{ "op": "pub", "address": "000c:3e01", "y": 0.1, "ack": true }]
← [{ "op": "pub:ok", "address": "000C:3E01", "x": "2026-11-02T10:00:01.25Z", "y": 0.10000000149011612 }]

→ [{ "op": "pub", "address": "000C:4F01", "x": "2026-11-02T10:00:00Z", "y": 23.5 }]

→ [{ "op": "pub", "address": "000C:3701", "y": "18446744073709551615", "ack": true }]
← [{ "op": "pub:ok", "address": "000C:3701", "x": "2026-11-02T10:00:02Z", "y": "18446744073709551615" }]

→ [{ "op": "pub", "address": "000C:5D01", "x": 0.1, "y": 1.5, "ack": true }]
← [{ "op": "pub:ok", "address": "000C:5D01", "x": 0.0999755859375, "y": 1.5 }]

→ [{ "op": "pub", "address": "000C:3701", "y": 42 }]
← [{ "op": "pub:error", "address": "000C:3701", "y": 42, "code": "bad_request", "message": "y must be a decimal string (u64)" }]

→ [{ "op": "pub", "address": "0063:3E01", "y": "hot" }]
← [{ "op": "pub:error", "address": "0063:3E01", "y": "hot", "code": "forbidden", "message": "Missing P claim on Group 0063" }]

→ [{ "op": "sub", "address": "12:3E01" }]
← [{ "op": "sub:error", "address": "12:3E01", "code": "bad_request", "message": "address must be an address: 4 + 4 hex digits, e.g. \"000C:3E01\"" }]
  • The connection is subscribed to Group 000C, so it also receives a meas for each publish (shown once).
  • The address is read in either case: "000c:3e01" is answered as "000C:3E01".
  • The f32 0.1 is stored as the nearest f32, 0.10000000149011612, and that is what comes back, in pub:ok, in every meas and in REST. Over bytes it is CD CC CC 3D: the same stored value. The f16 x 0.1 likewise comes back as 0.0999755859375.
  • The u64 is a string both ways, even for small values; 42 as a number is refused, and the pub:error repeats it as sent.
  • Without P on Group 0063, the pub is forbidden before its value ("hot", not a number) is looked at: the same order as over bytes.
  • An :error repeats only the op's own fields, as sent ("12:3E01"), never the ack.

Problems that can't be tied to one op (invalid JSON, not an array, more than 1000 ops, an op that isn't an object) are answered with one error op, and an unknown op names what it received. The connection stays open:

JSON
← [{ "op": "error", "code": "bad_request", "message": "A message must be a non-empty JSON array of op objects" }]
← [{ "op": "error", "code": "unknown_op", "received_op": "publish", "message": "Unknown op \"publish\"; expected one of \"pub\", \"sub\", \"sub-group\", \"unsub\", \"unsub-group\"" }]

Those error ops have no Group, so they aren't written to the audit log.

Fan-out and delivery#

A measurement at address GGGG:TTTT reaches:

  1. the connections subscribed to the whole Group (sub-group GGGG); and
  2. the connections subscribed to that exact address (sub GGGG:TTTT).
  • Once per connection, even when it is subscribed both ways, and publisher included.
  • Stored first, then delivered. pub:ok means stored; the pubs of one message are stored in one batch.
  • The measurements one message publishes reach each subscriber as one message per Group, of meas ops in publish order: a message publishing to Groups 0015 and 0016 arrives as two messages to a connection subscribed to both.
  • Live delivery is best-effort: catch up over REST (raw from the last x you received).
  • sub, unsub and the group forms are idempotent. unsub needs no flag, and doesn't undo a sub-group (nor unsub-group a sub). A sub to a Tag with an invalid dtype is a sub:error with bad_request.

Close codes#

CodeWhy
1003A frame of the wrong type: text on /connect/bytes, binary on /connect/json.
1007bytes only: a malformed message. count is 0, an op is truncated, an op code is unknown, a pub's Tag has an invalid dtype, or bytes are left over.
1008The Domain was deactivated or released.
1009bytes only: more than 1000 ops in a message. Either encoding: a frame larger than 256 KiB.
1011An unexpected server error. Its details are never logged, because they could hold your data. Reconnect.

A malformed JSON message doesn't close the connection: it is answered with an error op (above). To see each close for yourself, send a frame by hand from the Live tool of the Playground's Metric Broker:

Minimal client without a library#

A bytes connection in plain browser JavaScript: subscribe to a Group, publish a reading, and decode what comes back.

JavaScript
const { token } = await fetch('/api/thuum-token').then((r) => r.json());
const ws = new WebSocket(
	`wss://api.thuum.dev/api/acme/metric/connect/bytes?token=${encodeURIComponent(token)}`
);
ws.binaryType = 'arraybuffer';

const xBytes = (tag) => [8, 8, 8, 8, 8, 2, 4][tag >> 12]; // x as the server sends it
const yBytes = (tag) => 1 << ((tag >> 8) & 3);
const CODES = ['', 'forbidden', 'bad_request', 'unknown_op', 'limit', 'internal'];

function decode(buffer) {
	const v = new DataView(buffer);
	const ops = [];
	let at = 2;
	for (let n = v.getUint16(0, true); n > 0; n--) {
		const code = v.getUint8(at);
		const op = { code, group: v.getUint16(at + 1, true) };
		at += 3;
		if (![0x15, 0x16, 0x19, 0x1a].includes(code)) {
			op.tag = v.getUint16(at, true);
			at += 2;
		}
		if (code === 0x11 || code === 0x12 || code === 0x21) {
			const x = code === 0x12 && op.tag >> 12 <= 3 ? 0 : xBytes(op.tag); // pub:error: as sent
			op.x = new DataView(buffer, at, x);
			op.y = new DataView(buffer, at + x, yBytes(op.tag));
			at += x + yBytes(op.tag);
		}
		if (code >= 0x11 && code <= 0x1a && code % 2 === 0) {
			op.error = CODES[v.getUint16(at, true)];
			at += 2;
		}
		ops.push(op);
	}
	return ops;
}

ws.addEventListener('message', ({ data }) => {
	for (const op of decode(data)) {
		if (op.code === 0x21 && op.tag === 0x3e01) {
			const ns = op.x.getBigUint64(0, true);
			console.log(new Date(Number(ns / 1_000_000n)), op.y.getFloat32(0, true));
		} else if (op.error) {
			console.warn('error', op);
		}
	}
});
await new Promise((resolve) => ws.addEventListener('open', resolve, { once: true }));

// count 1 · sub-group (ack) · group 000C
const sub = new DataView(new ArrayBuffer(5));
sub.setUint16(0, 1, true);
sub.setUint8(2, 0x06);
sub.setUint16(3, 0x000c, true);
ws.send(sub.buffer);

// count 1 · pub · address 000C:3E01 (sns_f32, id 1) · y 23.5
const pub = new DataView(new ArrayBuffer(11));
pub.setUint16(0, 1, true);
pub.setUint8(2, 0x01);
pub.setUint16(3, 0x000c, true);
pub.setUint16(5, 0x3e01, true);
pub.setFloat32(7, 23.5, true);
ws.send(pub.buffer);

With the client#

@thuum/client speaks both encodings through one socket class, MetricBrokerSocket (json by default), and encodes and decodes the frames for you: values are numbers, and times and 64-bit integers bigint. From a domain client, or directly with a token your server gave the browser:

TypeScript
import { MetricBrokerSocket, formatTime, nowTime } from '@thuum/client';

const socket = acme.metricBroker.connect({ encoding: 'bytes' });
// or: new MetricBrokerSocket({ baseUrl: 'https://api.thuum.dev', domain: 'acme', token, encoding: 'bytes' })

socket.onMeas(({ address, x, y }) => console.log(address, formatTime(x as bigint), y));
socket.onError((error) => console.warn(error.code, error.message)); // every error, ack or not
await socket.ready;

await socket.subGroup('000C', { ack: true }); // resolves on sub-group:ok
await socket.pub({ address: '000C:3E01', y: 23.5 }); // sns_f32; no ack: resolves once sent
const stored = await socket.pub(
	{ address: '000C:4F01', x: nowTime(), y: 21.5 }, // cts_f64: the client sets x
	{ ack: true } // resolves once stored, with x as stored
);

const results = await socket.send([
	{ op: 'pub', group: 0x000c, tag: 0x3e01, y: 24, ack: true }, // raw ops: Group and Tag as numbers
	{ op: 'sub', group: 0x000d, tag: 0x3e01, ack: true }
]); // one message; one { status: 'sent' | 'ok' | 'error' } per op

The socket takes addresses as text ('000C:3E01', '000C') or as numbers ({ group: 12, tag: 0x3e01 }, 12) and writes them in the connection's format; measurements and replies carry both (address, group, tag).

An ack op rejects with MetricBrokerSocketError (forbidden, bad_request, …, or closed); a value that doesn't fit the Tag's dtype rejects with MetricCodecError before anything is sent. Over json the socket writes values by the rules above (an f32 0.1 goes out as 0.10000000149011612, a bigint u64 as a decimal string) and also accepts their JSON string forms as input: an ISO 8601 time for x, a decimal string for a 64-bit y. See Sockets.