# KRATEO Pulse: Public Read API (v1) A live, read-only feed of a Pulse room: where the crowd is on the mood board, how hot it is, and how spread out. Two endpoints, one credential, three payload shapes. Pulse is a crowd mood board. People place a dot on a 2D field on their phone, the relay aggregates every dot into a density field, and this API hands you that field plus a handful of scalars, either as one-shot JSON or as a live Server-Sent Events stream. Built for VJs driving TouchDesigner, Resolume, MadMapper, Notch, Max/MSP and p5, for the OSC bridge, and for whoever is curling the relay at 2am in a venue to find out whether it is working. **Base URL:** `https://pulse.krateo.me` **This document is served by the relay itself**, raw, at `GET /docs`. Whoever handed you a read key can hand you `https://pulse.krateo.me/docs` with it. --- ## Contents - [30 seconds](#30-seconds) - [Getting a read key](#getting-a-read-key) - [Auth](#auth) - [Endpoints](#endpoints) - [The payload](#the-payload) - [Axes, and the one trap](#axes-and-the-one-trap) - [Decoding a grid frame](#decoding-a-grid-frame) - [The stream](#the-stream) - [Limits](#limits) - [Errors](#errors) - [The v1 stability promise](#the-v1-stability-promise) - [Worked example: driving visuals](#worked-example-driving-visuals) - [Worked example: in a browser](#worked-example-in-a-browser) - [Worked example: is this thing on](#worked-example-is-this-thing-on) - [What is deliberately not here](#what-is-deliberately-not-here) --- ## 30 seconds ```bash export RELAY=https://pulse.krateo.me export SID=d70f0319 export KEY=pk_0000000000000000000000fake # your read key, from the host curl -s "$RELAY/v1/sessions/$SID/snapshot?format=scalars" \ -H "Authorization: Bearer $KEY" ``` ```json { "schema": 1, "sessionId": "d70f0319", "format": "scalars", "t": 1785741471.405, "participantCount": 6, "crowdCount": 5, "scalars": { "count": 5, "average": { "x": 0.41200000000000003, "y": 0.5900000000000001 }, "centroid": { "x": 0.3630769230769231, "y": 0.6392307692307694 }, "energy": 0.78, "spread": 0.4454955355436785 }, "axes": "x and y are 0..1. y follows screen coordinates: 0 is the top edge, which is the yHigh edge label.", "settings": { "gridResolution": 48, "xAxisLabel": "ENERGY", "yAxisLabel": "MOOD", "edgeLabels": { "xLow": "CHILL", "xHigh": "HYPE", "yLow": "DARK", "yHigh": "EUPHORIC" }, "decayGrace": 600, "decayDuration": 600, "updateCooldown": 0 }, "stream": "/v1/sessions/d70f0319/stream?format=scalars" } ``` Then follow the `stream` URL for the same thing live, here at two frames a second: ```bash curl -N "$RELAY/v1/sessions/$SID/stream?format=scalars&hz=2&key=$KEY" ``` > Every response in this document is real output from a running relay. The read > keys shown are fake: `pk_0000000000000000000000fake` is not a key, it is a > placeholder shaped like one. --- ## Getting a read key You do not mint your own. The host of the session gives you one. In the Pulse Web host page (`/host`) there is a collapsed **DATA FEED** panel next to the QR code. It holds the read key, a ready-made stream URL, and a **REGENERATE KEY** button. A host hands you the key or the whole URL before doors. If the host is running the session through the API, `POST /sessions` returns the key once, on both create (201) and resume (200): ```bash curl -s -X POST "$RELAY/sessions" ``` ```json {"sessionId":"622737e2","hostToken":"deadbeefdeadbeefdeadbeefdeadbeef","readKey":"pk_0000000000000000000000fake"} ``` *(both credentials above are placeholders in the real shape: a host token is 32 hex characters, a read key is `pk_` plus 26 Crockford base32 characters)* **One address holds at most 5 sessions at a time.** Past that, `POST /sessions` answers `429` with `scope: "ip"` and a `limit`. It counts sessions *held*, not sessions created, so a rig that ends each session when it is done never meets it. Posting `{sessionId, hostToken}` to resume a session you already hold costs no slot, which is what a host page reloading or a client reconnecting after a relay restart does. **A read key does not survive its session.** The relay holds sessions in memory only, and a session ends in three ways: the host deletes it, the last device leaves and it is reaped 30 seconds later, or the relay restarts. All three destroy the key. When the session comes back, it comes back with a **new** key. If your rig is set up for a residency, plan on re-fetching the key rather than baking it into a config file once. The host can also revoke a key at any time with **REGENERATE KEY**. Every stream authenticated with the old key is dropped immediately with `event: end`, `reason: key_revoked`, and the old key answers 403 from then on. --- ## Auth **The read key, and nothing else.** Two presentation forms: | Form | Example | When | |------|---------|------| | Header | `Authorization: Bearer pk_...` | preferred, always, wherever you can set headers | | Query | `?key=pk_...` | when you cannot, which in practice means `EventSource` | The query form exists because the browser `EventSource` API **cannot set request headers**. There is no way to attach an `Authorization` header to an `EventSource`, so a browser consumer has no option but the query string. That is why the form exists, not a suggestion to use it. It has a real cost. A key in a URL lands in our access log, in the reverse proxy's access log, in your shell history, and in any `Referer` a page then sends. Treat a key you have ever put in a URL as one that will need regenerating eventually. Anywhere you control the request (Python, Max, a Node bridge, curl), use the header. **The host token is refused here, deliberately.** It is the more privileged credential: it can change settings, clear the board and end the session. It does not work on `/v1` and never will, so that no integration guide anywhere can grow an "or just use your host token" line. A host token on a read endpoint gets the same 403 as no credential at all: ```bash curl -s "$RELAY/v1/sessions/$SID/snapshot" -H "Authorization: Bearer $HOST_TOKEN" ``` ```json { "error": "Invalid read key", "hint": "Pass the session readKey as \"Authorization: Bearer pk_...\" or as ?key=pk_... The hostToken is not accepted here." } ``` **Key format.** `pk_` followed by 26 lowercase Crockford base32 characters, 128 bits of entropy. The alphabet has no `i`, `l`, `o` or `u`, so there is no `1`/`l` or `0`/`O` to get wrong when a key is read out across a loud room. Keys are matched case-insensitively and trimmed, so a key that came back from a phone in the wrong case still works. **Treat the key as an opaque string.** Match on the `pk_` prefix if you must recognise one. Never match on its length or its alphabet: both may grow. **CORS is open.** Every `/v1` response carries `Access-Control-Allow-Origin: *`, so a browser page on any origin can read the API with a key. --- ## Endpoints | Method | Path | Auth | What | |--------|------|------|------| | `GET` | `/v1/sessions/:id/snapshot` | read key | one shot of the current state, as JSON | | `GET` | `/v1/sessions/:id/stream` | read key | the same state as Server-Sent Events at a fixed rate | That is the whole surface. `GET /v1/sessions/:id` is a 404 on purpose; use `GET /sessions/:id` for session info without a credential (see [What is deliberately not here](#what-is-deliberately-not-here)). ### Parameters | Parameter | Endpoints | Default | Accepted | Behaviour outside the range | |-----------|-----------|---------|----------|-----------------------------| | `format` | both | `grid` | `grid`, `positions`, `scalars` | **400**, not a silent default | | `hz` | stream only | `10` | `0.01` to `10` | **clamped**, never rejected | | `key` | both | none | your read key | 403 | `hz` is clamped rather than refused, because asking for 30 is a reasonable thing to try and the useful answer at 2am is the fastest rate that exists, not an error. What keeps that from hiding a typo is that the `hello` event reports the rate the stream is actually running at, so you find out in the first thing you read: | you ask for | you get | why | |-------------|---------|-----| | `hz=30` | `10` | the relay's own field ticks at 10 Hz; above that you would be told the same thing three times | | `hz=1O` (letter O) | `1` | `parseFloat` stops at the letter; `hello` says `"hz":1` so you catch it | | `hz=0` | `0.01` | the floor, one frame per 100 seconds, for logging a room rather than rendering it | `format` is **not** clamped, because a typo that silently gave you the default would leave you with a working stream in the wrong shape and no clue why: ```bash curl -s "$RELAY/v1/sessions/$SID/snapshot?format=heatmap&key=$KEY" ``` ```json { "error": "Unknown format: heatmap", "hint": "format must be one of grid, positions, scalars. Default is grid." } ``` ### Response headers The snapshot is `application/json`, pretty-printed, because its first reader is a person in a terminal. The stream is: ``` HTTP/1.1 200 OK Access-Control-Allow-Origin: * Content-Type: text/event-stream; charset=utf-8 Cache-Control: no-store Connection: keep-alive X-Accel-Buffering: no Vary: Accept-Encoding Transfer-Encoding: chunked ``` `X-Accel-Buffering: no` is what stops nginx collecting a 10 Hz stream into bursts every few KB. If you are running Pulse behind your own proxy, keep it. ### Compression Both endpoints gzip **only when you send `Accept-Encoding: gzip`**. That keeps `curl -N` readable by default, while every browser `EventSource` and every HTTP library that defaults to gzip gets the compressed stream without doing anything. Measured on the res 48 grid snapshot above: 4195 bytes plain, 2000 bytes gzipped. On a res 96 grid stream the ratio is around 5x. If you are hand-rolling an SSE client, note that you have to either decompress incrementally or send `Accept-Encoding: identity`. Every frame is flushed with `Z_SYNC_FLUSH`, so an incremental decompressor gets each frame as it lands; a library that waits for the end of the body will wait forever. --- ## The payload The three formats are **one envelope with a different heavy part**. Every frame and every snapshot carries: | Field | Type | Meaning | |-------|------|---------| | `schema` | int | payload version, currently `1`. On every frame and on `end`, not only on `hello`, because a frame is the thing you record and replay out of a log and it has to identify itself on its own | | `t` | float | server wall clock, seconds since the Unix epoch. Same unit and origin as a position's `ts` | | `participantCount` | int | **every** connected device, including the screens that only watch: the host's dashboard, a projector, an embed. Almost never the number you want on its own; see `crowdCount` | | `crowdCount` | int | the crowd: connected devices that can place a dot. The screens are not in it. **This is "how many people are in the room"** | | `scalars` | object | the derived numbers, below | And then, by format: | `format` | Adds | Cost | Field rendered? | |----------|------|------|-----------------| | `grid` (default) | `res`, `max`, `grid` | flat, whatever the crowd size | yes | | `positions` | `positions[]` | one object per dot, so it costs what the room costs | no | | `scalars` | nothing | flat and tiny | no | A snapshot carries five more fields on top: `sessionId`, `format`, `axes`, `settings` and `stream`, plus `maxRes` and `encoding` for `format=grid`. Stream frames carry none of those, because at 10 Hz the axis note and the decode instructions would cost more than the data. They are in the stream's `hello` event instead. ### `scalars` | Field | Type | Range | Meaning | |-------|------|-------|---------| | `count` | int | 0 and up | dots currently on the board | | `average` | `{x, y}` or `null` | 0..1 each | **unweighted** mean position. `null` when `count` is 0 | | `centroid` | `{x, y}` or `null` | 0..1 each | energy-weighted mean position. `null` when `count` is 0 | | `energy` | float | exactly `0`, or `0.2`..`1.0` | mean of every dot's energy | | `spread` | float | 0..1 | RMS distance of the dots from `centroid`, normalised so that mass split between opposite corners maps to 1.0 | Three things about these that are easy to get wrong: **There are three counts and they answer three different questions.** They sit next to each other and picking the wrong one is the easiest mistake on this surface: | Field | Counts | How it differs from the room | |-------|--------|------------------------------| | `participantCount` | every connected device | too high: the host's dashboard, a projector and an embed are each a device | | `crowdCount` | connected devices that can place a dot | this is the room | | `scalars.count` | dots on the board | too low: a phone that joined and has not voted yet is missing, and so is one whose dot has decayed | **If you want "how many people are in this room", that is `crowdCount`.** `participantCount` over-counts it by however many screens are attached: a session with a host dashboard and a projector up and nobody in the room yet reads `participantCount: 2` and `crowdCount: 0`. `scalars.count` under-counts it, because it is dots rather than devices. `participantCount` is the older field and it is frozen as connected devices, screens included, so it keeps that meaning forever; see [the stability promise](#the-v1-stability-promise). `crowdCount` was added next to it rather than by narrowing it. Going the other way, `crowdCount` is an upper bound on `scalars.count` and not the same number. Every dot on the board belongs to a device that is still connected: a dot is dropped the moment its device disconnects. So `count` climbs toward `crowdCount` as the room votes and falls back as dots decay, and `count` above `crowdCount` only happens on a demo session, where the relay injects dots that never had a device. **`energy` never sits between 0 and 0.2.** A dot's energy is clamped to `[0.2, 1.0]` on the way in, so the mean of any non-empty board is at least 0.2. The only way to see `0` is an empty board. If you map energy to brightness, map `0.2..1.0`, not `0..1`, or the low end of your range is dead. **`average` and `centroid` sit next to each other and are not the same thing.** `centroid` weights each dot by its energy and is usually what you want. `average` is the plain mean and is frozen that way forever (see [the stability promise](#the-v1-stability-promise)). An empty board, verbatim: ```json {"count":0,"average":null,"centroid":null,"energy":0,"spread":0} ``` Guard for `null` before you read `.x`. `spread` is computed in one pass, so on a single dot it can come back as a floating-point epsilon like `1.86e-9` rather than exactly `0`. Do not test it for equality with zero. ### `format=positions` ```bash curl -s "$RELAY/v1/sessions/$SID/snapshot?format=positions" -H "Authorization: Bearer $KEY" ``` ```json { "schema": 1, "sessionId": "d70f0319", "format": "positions", "t": 1785740702.57, "participantCount": 6, "crowdCount": 5, "scalars": { "count": 5, "average": { "x": 0.41200000000000003, "y": 0.5900000000000001 }, "centroid": { "x": 0.3630769230769231, "y": 0.6392307692307694 }, "energy": 0.78, "spread": 0.4454955355436785 }, "positions": [ { "x": 0.25, "y": 0.75, "energy": 1, "ts": 1785740688.238 }, { "x": 0.27, "y": 0.73, "energy": 1, "ts": 1785740688.302 }, { "x": 0.24, "y": 0.77, "energy": 0.9, "ts": 1785740688.365 }, { "x": 0.8, "y": 0.2, "energy": 0.6, "ts": 1785740688.429 }, { "x": 0.5, "y": 0.5, "energy": 0.4, "ts": 1785740688.495 } ], "axes": "x and y are 0..1. y follows screen coordinates: 0 is the top edge, which is the yHigh edge label.", "settings": { "gridResolution": 48, "…": "…" }, "stream": "/v1/sessions/d70f0319/stream?format=positions" } ``` *(real values, reflowed for width: the relay pretty-prints one key per line, and `settings` arrives in full as in the first example above)* | Field | Type | Range | Meaning | |-------|------|-------|---------| | `x` | float | 0..1 | horizontal, 0 is the `xLow` edge label | | `y` | float | 0..1 | vertical, **0 is the top**, the `yHigh` edge label | | `energy` | float | 0.2..1.0 | how hard the dot was placed | | `ts` | float | epoch seconds | when the dot was last moved. Same time base as `t` | Positions are unordered and the array is rebuilt every frame. **There is no id on a position**, so you cannot follow one person's dot across frames. That is deliberate: the only handle that would let you do it identifies a live connection to the relay, and a mood board should not hand out the ability to track an individual through a room. If you want spatial structure over time, use `format=grid`, which carries exactly that and nothing about individuals. `positions` is the expensive format and it is the crowd, not you, who sets its price: one object per dot, about 96 bytes each, so a full room at 10 Hz is roughly 460 KB/s. See [Limits](#limits). ### `format=grid` The density field the host and the crowd are looking at, rendered server side. Flat cost whatever the crowd size, which is why it is the default. ```bash curl -s "$RELAY/v1/sessions/$SID/snapshot" -H "Authorization: Bearer $KEY" ``` ```json { "schema": 1, "sessionId": "d70f0319", "format": "grid", "t": 1785741471.441, "participantCount": 6, "crowdCount": 5, "scalars": { "count": 5, "average": { "x": 0.41200000000000003, "y": 0.5900000000000001 }, "centroid": { "x": 0.3630769230769231, "y": 0.6392307692307694 }, "energy": 0.78, "spread": 0.4454955355436785 }, "res": 48, "max": 1.914867877960205, "grid": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgME…AAAAAAAA", "axes": "x and y are 0..1. y follows screen coordinates: 0 is the top edge, which is the yHigh edge label.", "settings": { "gridResolution": 48, "xAxisLabel": "ENERGY", "yAxisLabel": "MOOD", "edgeLabels": { "xLow": "CHILL", "xHigh": "HYPE", "yLow": "DARK", "yHigh": "EUPHORIC" }, "decayGrace": 600, "decayDuration": 600, "updateCooldown": 0 }, "stream": "/v1/sessions/d70f0319/stream?format=grid", "maxRes": 96, "encoding": { "grid": "base64 of res*res uint8, row major: cell (x, y) is byte y * res + x", "density": "(byte / 255) ^ 2 * max", "note": "square-root companded, so the faint outskirts do not band" } } ``` *(real values, reflowed for width. The `grid` string is elided in the middle: at res 48 it is 3072 base64 characters decoding to 2304 bytes, on one line. A complete, unelided grid frame is [below](#a-complete-frame-you-can-check-by-hand))* | Field | Type | Meaning | |-------|------|---------| | `res` | int | grid edge length in cells. The grid is `res` x `res` | | `max` | float | peak density in **this frame**. Changes every frame | | `grid` | string | base64 of `res * res` uint8 cells | | `maxRes` | int | the read consumers' resolution ceiling, currently 96. Snapshot and `hello` only | | `encoding` | object | the decode rule, inline, so you never need this file open. Snapshot and `hello` only | `max` being per frame is the thing to notice. It means `(byte / 255) ^ 2` is **normalised** density, 0..1, with the brightest cell in the frame always at exactly 1.0, and multiplying by `max` recovers **absolute** density that is comparable across frames. Which one you want depends on what you are driving: - **normalised** (`(byte/255)^2`) gives you an auto-gain image. The field always fills its range and a nearly empty room looks as bright as a full one. Good for a texture. - **absolute** (`(byte/255)^2 * max`) tracks how busy the room actually is. Good for anything that should get brighter as the room fills. On an empty board `max` is `0` and every cell is `0`. **Guard against dividing by `max`** or you will get `NaN` in your shader the first time you point it at a room before doors. --- ## Axes, and the one trap ``` x = 0 x = 1 xLow label xHigh label +--------------------------------------------+ y = 0 <- yHigh label | | | | | + | | centroid | | | +--------------------------------------------+ y = 1 <- yLow label ``` `x` and `y` are both `0..1`. `x` is ordinary: 0 is the left edge, the `xLow` label. **`y` follows screen coordinates. `y = 0` is the TOP edge, and the top edge is the `yHigh` label.** Read that twice. On the default session, `yHigh` is `EUPHORIC` and `yLow` is `DARK`, so a euphoric room reports **`y` near 0**, not near 1. Everything is consistent about this: `scalars.average.y`, `scalars.centroid.y`, every `positions[].y`, and row 0 of the grid are all the top of the board. This is the one thing that costs an afternoon when it is not written down, which is why it ships in the payload itself as `axes` on every snapshot and in every stream `hello`. If your renderer's origin is bottom-left, which is true of OpenGL, GLSL, p5 in WEBGL mode, TouchDesigner's TOP coordinate space and most OSC-driven lighting desks, flip it once at the boundary: ```python y_up = 1.0 - y ``` and never again. Flipping in two places is how visuals end up upside down. Canvas 2D, ImageData and most image libraries are already top-left, so they need no flip. --- ## Decoding a grid frame This is the one thing you cannot guess from the payload, so here it is three times: as a rule, as a complete frame small enough to check by hand, and as working code. ### The rule ``` bytes = base64_decode(grid) length is exactly res * res cell(x,y)= bytes[y * res + x] row major, y is the row index density = (cell / 255) ^ 2 * max square-root companded ``` The square root companding is not decoration. Linear uint8 bands visibly in the faint outskirts of the field, because the renderer stretches the low end. Storing the square root and squaring on the way out cuts the worst rendered-intensity error by 4.6x. Skip the squaring and your field will look washed out and wrong, not subtly wrong. ### A complete frame you can check by hand Here is a real snapshot from a session set to `gridResolution: 8` with a single dot at `x = 0.75, y = 0.25`. Eight by eight is 64 bytes, so the whole grid fits on one line: ```json { "schema": 1, "sessionId": "e83c149c", "format": "grid", "t": 1785740776.266, "participantCount": 2, "crowdCount": 1, "scalars": { "count": 1, "average": { "x": 0.75, "y": 0.25 }, "centroid": { "x": 0.75, "y": 0.25 }, "energy": 1, "spread": 0 }, "res": 8, "max": 0.7304432988166809, "grid": "tMHV5/T7/fy1w9fp9v3+/rbD1+r3/f//ssDU5vL5+/qrt8rb6O7w75+rvMzY3t/fkZytu8bLzMyIk6Kvub6/vw==" } ``` *(the `grid` string is complete and unmodified. The trailing `axes`, `settings`, `stream`, `maxRes` and `encoding` fields are trimmed here to keep the frame short)* Decoded to bytes and laid out as rows: ``` x=0 x=1 x=2 x=3 x=4 x=5 x=6 x=7 y=0 180 193 213 231 244 251 253 252 y=1 181 195 215 233 246 253 254 254 y=2 182 195 215 234 247 253 255 255 <- peak row y=3 178 192 212 230 242 249 251 250 y=4 171 183 202 219 232 238 240 239 y=5 159 171 188 204 216 222 223 223 y=6 145 156 173 187 198 203 204 204 y=7 136 147 162 175 185 190 191 191 ``` The peak byte, `255`, is at cell `(6, 2)`. The dot was placed at `x = 0.75`, `y = 0.25`, and `0.75 * 8 = 6`, `0.25 * 8 = 2`. **Upper right, and its row index is near the start of the byte array**, which is the y orientation made concrete: a dot near the top of the board lands in the first rows of the grid. Its density is `(255 / 255) ^ 2 * 0.7304432988166809 = 0.7304432988166809`, which is `max`, as it must be for the peak cell. Note how much of the grid is lit for one dot. The field is blurred with a sigma of 3.5 cells, which at res 8 is most of the board. At the default res 48 the same dot covers a sensible fraction of the picture. ### Python Standard library only, so it runs in TouchDesigner's bundled Python with nothing installed. Drop it in a Text DAT. ```python import base64 def decode_grid(frame): """frame: a parsed grid frame or snapshot. Returns rows of densities: cells[y][x] is the density at grid cell (x, y).""" res = frame["res"] mx = frame["max"] raw = base64.b64decode(frame["grid"]) assert len(raw) == res * res, f"expected {res * res} bytes, got {len(raw)}" return [ [(raw[y * res + x] / 255.0) ** 2 * mx for x in range(res)] for y in range(res) ] def peak(frame): """Brightest cell, as 0..1 coordinates in the same orientation as scalars.centroid: y = 0 is the top edge, the yHigh label.""" cells = decode_grid(frame) res = frame["res"] best_v, best_x, best_y = -1.0, 0, 0 for y in range(res): for x in range(res): if cells[y][x] > best_v: best_v, best_x, best_y = cells[y][x], x, y return {"x": (best_x + 0.5) / res, "y": (best_y + 0.5) / res, "density": best_v} ``` Run against the res 8 frame above and against a res 48 frame from a session with a dot cluster at `x = 0.253, y = 0.75`: ``` res 8 max 0.7304 peak {'x': 0.8125, 'y': 0.3125, 'density': 0.7304432988166809} res 48 max 2.2614 peak {'x': 0.2604, 'y': 0.7396, 'density': 2.2613613605499268} ``` Both land on the dots. The res 8 answer reads as `0.8125, 0.3125` rather than `0.75, 0.25` because `peak` returns the **centre** of the winning cell and one cell is 12.5% of the board at that resolution. At res 48 the same calculation lands within half a cell. With numpy, if you have it: ```python import base64 import numpy as np def decode_grid_np(frame): """Returns a (res, res) float32 array indexed [y][x].""" res = frame["res"] raw = np.frombuffer(base64.b64decode(frame["grid"]), dtype=np.uint8) dens = (raw.astype(np.float32) / 255.0) ** 2 * frame["max"] return dens.reshape(res, res) ``` `np.unravel_index(arr.argmax(), arr.shape)` on the res 8 frame above gives `(2, 6)`, which is `(y, x)`: row 2, column 6. Same cell as the pure-Python version found, in the order numpy indexes it. ### JavaScript ```js function decodeGrid(frame) { const { res, max, grid } = frame; const bin = typeof atob === 'function' ? atob(grid) // browser : Buffer.from(grid, 'base64').toString('binary'); // node const out = new Float32Array(res * res); for (let i = 0; i < res * res; i++) { const c = bin.charCodeAt(i) / 255; out[i] = c * c * max; // undo the companding } return out; // out[y * res + x] is cell (x, y) } ``` Verified against the same two frames: ``` res 8 peak cell 6 2 normalised 0.8125 0.3125 density 0.7304 (max 0.7304) res 48 peak cell 12 35 normalised 0.2604 0.7396 density 2.2614 (max 2.2614) ``` ### The resolution ceiling: read `res` from the frame, always A host can set `gridResolution` anywhere from 8 to 256 for their own screen. **Read consumers are capped at 96**, independent of what the host chose, so a session running at 256 sends you 96. That is a clamp, not a refusal: a host dragging a slider for their own view must not be able to disconnect you mid set, and a stream that quietly gets coarser is a much better failure than one that dies. Nothing is hidden by it, because `res` travels on **every grid frame** and `hello` reports both numbers next to the host's setting. From a live session with the host at 256, the relevant part of the `hello`: ```json { "format": "grid", "res": 96, "maxRes": 96, "settings": { "gridResolution": 256, "…": "…" } } ``` and the grid in that frame decodes to exactly 9216 bytes, which is 96 x 96. So: **allocate your texture from `res` on the frame you are decoding, not from `settings.gridResolution`, and not once at startup.** The host can change it mid-session and the next frame will simply arrive at a different size. --- ## The stream ```bash curl -N "$RELAY/v1/sessions/$SID/stream?format=scalars&hz=2&key=$KEY" ``` ``` retry: 500 event: hello data: {"schema":1,"sessionId":"d70f0319","format":"scalars","hz":2,"cadence":"fixed 2 Hz, independent of crowd activity; interpolate between frames","heartbeatSec":15,"maxLifetimeSec":1800,"replay":false,"axes":"x and y are 0..1. y follows screen coordinates: 0 is the top edge, which is the yHigh edge label.","settings":{"gridResolution":48,"xAxisLabel":"ENERGY","yAxisLabel":"MOOD","edgeLabels":{"xLow":"CHILL","xHigh":"HYPE","yLow":"DARK","yHigh":"EUPHORIC"},"decayGrace":600,"decayDuration":600,"updateCooldown":0}} event: frame data: {"schema":1,"t":1785740804.678,"participantCount":6,"crowdCount":5,"scalars":{"count":5,"average":{"x":0.41200000000000003,"y":0.5900000000000001},"centroid":{"x":0.3630769230769231,"y":0.6392307692307694},"energy":0.78,"spread":0.4454955355436785}} event: frame data: {"schema":1,"t":1785740805.178,"participantCount":6,"crowdCount":5,"scalars":{"count":5,"average":{"x":0.41200000000000003,"y":0.5900000000000001},"centroid":{"x":0.3630769230769231,"y":0.6392307692307694},"energy":0.78,"spread":0.4454955355436785}} ``` Server-Sent Events rather than a WebSocket, because SSE reconnects on its own, survives every proxy that has ever eaten an upgrade, and is readable with one command. ### Events | Event | When | Carries | |-------|------|---------| | `hello` | once, first | everything static: `schema`, `sessionId`, `format`, `hz`, `cadence`, `heartbeatSec`, `maxLifetimeSec`, `replay`, `axes`, `settings`, plus `res`, `maxRes` and `encoding` for `format=grid` | | `frame` | every tick | the payload, and nothing else | | `end` | when the relay closes the stream on purpose | `{ schema, reason, t }` | Plus SSE comment lines for heartbeats, which are not events and which `EventSource` never surfaces to a handler. ### Cadence is fixed and has nothing to do with crowd activity The relay emits at `hz` whether the crowd is still or frantic. A frame is not a change notification: it is the complete state of the board at that instant. Two consecutive frames from a still room are byte-identical apart from `t`, and that is correct behaviour, not a bug. Interpolate between frames. That is what a lighting desk or a shader wants anyway, and it is what keeps a stream's cost dependent on the tick rate rather than on how fast people are tapping. ### Reconnection **`retry: 500` is sent once, before anything else**, so it is in place before the first drop rather than after it. A browser `EventSource` will reconnect after 2 seconds on any transport-level drop without being asked. Browsers default to 3 s (Chrome) or 5 s (Firefox); 2 s is quick enough that a flapping proxy is invisible during a set. **There is no replay.** No `id:` lines, no `Last-Event-ID`, no buffer. `hello` says `"replay": false` out loud. This is deliberate and it is the right answer: a frame is absolute state, so a consumer that dropped for 20 seconds wants the current frame and nothing else. Replaying 200 stale pictures of a room would drive a light rig through 20 seconds of history at reconnect. If you send a `Last-Event-ID` header it is ignored. So a reconnect costs you exactly one `hello` and nothing else. There is nothing to catch up on, ever. **Heartbeats after 15 seconds of silence**, as an SSE comment line: ``` : heartbeat 2026-08-03T07:10:08.288Z ``` `EventSource` ignores comments without waking a handler, while `curl` shows them, which is the split we want. A silent SSE connection is indistinguishable from a wedged one to everything between the relay and you: nginx `proxy_read_timeout` defaults to 60 s, hosted load balancers commonly sit at 60 s, and venue NAT tables drop idle flows sooner. Any stream above 1/15 Hz is carried by its own frames and never emits one; the heartbeat is what keeps the slow ones alive. The check runs on its own 5 s tick, so one lands 15 to 20 s into a silence. If you are hand-rolling a client: skip any line starting with `:`. ### `event: end` and its reasons A consumer that is merely disconnected cannot tell a revoked key from a dead relay from a lost route, and will reconnect into a 403 loop finding out. So the relay says which, when it can: ``` event: end data: {"schema":1,"reason":"key_revoked","t":1785740934.629} ``` | `reason` | What happened | Retry? | |----------|---------------|--------| | `max_lifetime` | the 30 minute cap. Scheduled, not a fault | **Yes, immediately.** This is normal | | `server_restarting` | the relay is being redeployed | **Yes**, after a few seconds | | `slow_consumer` | you stopped reading for 15 s and the relay let go of the socket | **Yes**, but fix your consumer first: you are not draining | | `over_budget` | the session or the relay went over its bandwidth ceiling and shed streams, newest first | **Yes**, after a pause. Consider a lower `hz` or a cheaper format | | `session_idle` | the last device left and the session was reaped 30 s later | Only if you expect people back. Its read key is now dead | | `session_ended` | the host ended the session | **No.** The key died with it | | `key_revoked` | the host regenerated the read key | **No.** Get the new key from the host | The last three mean your credential is gone. Retrying gets you a 403 or a 410 forever. Everything above them is worth retrying. An `EventSource` cannot see the difference on its own: it will reconnect after `retry:` regardless. If any of these matter to you, add an `end` handler and call `.close()` on the three that do not come back: ```js es.addEventListener('end', (e) => { const { reason } = JSON.parse(e.data); if (['session_ended', 'key_revoked', 'session_idle'].includes(reason)) es.close(); }); ``` ### The 30 minute lifetime **Every stream is closed after 30 minutes** with `event: end`, `reason: max_lifetime`. `hello` reports it as `maxLifetimeSec: 1800`. This is not a fault and it is not about you. SSE is one-way: you send a `GET` and then nothing, ever, so the relay has no way of knowing whether anyone is still reading. A concurrency cap that counts entries whose consumer left without saying so is a cap that fills up. Wall clock is the only thing SSE offers that does not depend on observing the peer. The cost of being wrong is one reconnect, and a reconnect costs nothing because there is no replay to miss. An `EventSource` handles it without any code from you. A hand-rolled client needs a loop. Wrapping the `stream()` generator from [the worked example below](#worked-example-driving-visuals): ```python import time while True: try: for event, data in stream(session_id, read_key): handle(event, data) except Exception as err: # the socket, a proxy, a 429 print("stream failed:", err) # Fell out: max_lifetime, or the network. Either way, reconnect. time.sleep(2) ``` Do not treat a scheduled close as an error. Log it at debug, not at error, or your 2am self will chase it. ### Backpressure: read your socket If you stop draining the connection, the relay **skips frames rather than queuing them**, so what one stuck consumer costs is capped at a single frame in flight. You lose nothing by missing frames, because the next one you read is current. If nothing is accepted for 15 seconds, the stream is dropped with `reason: slow_consumer` and the socket is destroyed rather than closed politely. If you see this, you have a consumer that is not reading, not a relay problem. --- #### If even the gap bothers you: overlap The relay sends `retry: 500`, so an `EventSource` is back half a second later and that is the whole outage. For a lighting rig or anything else running all evening that is usually beneath noticing, because a frame is absolute state: you hold the last values for half a second and then carry on. Nothing queues up and nothing is lost. If you want literally zero gap, do not wait for the close. `hello` tells you `maxLifetimeSec`, so you know when it is coming. Open the next stream a few seconds early, wait for its first `frame`, and only then drop the old one. You are briefly holding two streams, which costs you two of your eight per-session slots for a second or two, and the handover is seamless. This is the client's job and not something the relay can do for you: only you know whether your output can tolerate half a second of held values, and only you can decide when to open the second connection. ## Limits Every number here is measured against a running relay, and every one is derived from one assumed uplink: 1 Gbit/s, shared, on the VPS the relay runs on. The read API gets 10% of that and the crowd keeps the rest, because a read consumer that degrades is an inconvenience and a crowd that cannot see the board is a failed event. | Limit | Value | Scope | |-------|-------|-------| | Concurrent streams per session | **8** | one session | | Concurrent streams per source address | **10** | your machine, across all sessions | | Concurrent streams relay-wide | **200** | everyone | | Bandwidth per session | **1.6 MB/s** | one session's consumers together | | Bandwidth relay-wide | **12.5 MB/s** | everyone | | Grid resolution for read consumers | **96** | see [the resolution ceiling](#the-resolution-ceiling-read-res-from-the-frame-always) | | Maximum stream lifetime | **30 min** | every stream | | HTTP requests per source address | **100 per second** | every endpoint, snapshots included | | Sessions held per source address | **5** | `POST /sessions`, see [Getting a read key](#getting-a-read-key) | **Snapshots are not counted against any of the stream limits.** The endpoint a stranger runs first must not fail because somebody else is streaming. They are subject to the flat HTTP rate limit like every other request. ### What a stream costs Raw bytes per second, before compression. A stream is charged its uncompressed cost, because `Accept-Encoding` is your choice and the budget has to cover a relay full of consumers that decline it. ``` grid = hz x (300 + 4/3 x res^2) res is the capped one, so at most 96 positions = hz x (300 + 96 x devices) devices in the room, not dots scalars = hz x 300 ``` At 10 Hz that is `scalars` 2.9 KB/s, `grid` at res 48 32.9 KB/s, `grid` at res 96 122.9 KB/s, and `positions` in a room of 490 devices 462.3 KB/s. Those are the numbers the relay charges you, and the wire agrees closely: the two large formats were measured at 123.1 and 463.6 KB/s. `hz` scales all three linearly, so `hz=1` costs a tenth. **`positions` is priced on the number of devices in the room, not the number of dots on the board**, and this is the one that surprises people. Its payload is one object per dot, so its cost is set by the crowd and not by you. Eight `positions` streams at 10 Hz cost almost nothing in an empty room and 2.3 MB/s once 300 people have joined, which is more than the whole per-session ceiling. That is why streams can be shed after admission: the per-session and relay-wide ceilings are re-checked every 5 seconds against what streams cost **now**, and the newest streams go first. The integration that was set up before doors keeps running; the stream somebody opened during the set is the one that goes, with `reason: over_budget`. If you are building something that runs all night in a room that fills, use `format=grid` or `format=scalars`. Both are flat whatever the crowd size. ### When a stream limit is hit: 429 Real response, from opening a 9th stream on a session: ``` HTTP/1.1 429 Too Many Requests Retry-After: 10 ``` ```json { "error": "Too many read streams on this session", "hint": "A session serves at most 8 concurrent streams, and it has that many. Close one, or share a single stream between your consumers: every stream on a session carries the same state. Snapshots are not limited.", "limit": 8, "scope": "session" } ``` And from an 11th stream held open by one machine: ```json { "error": "Too many read streams from this address", "hint": "One address holds at most 10 concurrent streams across all sessions, and it has that many. Close one, or share a single stream between consumers on the same machine.", "limit": 10, "scope": "ip" } ``` Every stream 429 carries `Retry-After`, and a JSON body with `error`, `hint`, `limit` and `scope`. The five scopes, checked in this order, which is the order you can act on them: | `scope` | `Retry-After` | Means | What to do | |---------|---------------|-------|------------| | `session` | 10 s | 8 streams already open on this session | share one stream between your consumers; every stream on a session carries identical state | | `ip` | 10 s | your machine holds 10 across all sessions | close one, or share | | `session_bandwidth` | 30 s | this session's consumers are at 1.6 MB/s | lower `hz`, or move off `positions` | | `relay` | 30 s | 200 streams open relay-wide | wait and retry | | `bandwidth` | 30 s | the relay is at its 12.5 MB/s read budget | lower `hz`. The hint names an `hz` that would have fitted | All five are "not now" and not "not ever", so **retrying after `Retry-After` is the right thing for a program to do.** The bandwidth hints are computed live, so they tell you the actual numbers: `session_bandwidth` reports what the session is currently spending across how many streams and how many devices, and `bandwidth` reports how much headroom is left and what `hz` would fit in it. **A browser `EventSource` will not retry a 429 on its own.** The spec has it fail the connection on any non-200 without reconnecting. That is deliberate, not a thing to work around: a browser retrying every 2 seconds into a full relay is exactly the reconnect storm these limits exist to prevent. If you want a retry from a browser, write it yourself with a real backoff. ### The flat HTTP rate limit is a different 429 Separately from all of the above, the relay allows **100 HTTP requests per second per source address** across every endpoint. It applies to snapshots. Its refusal looks nothing like the ones above: ``` HTTP/1.1 429 Too Many Requests Content-Type: text/plain rate limit exceeded ``` **No `Retry-After`, no JSON, no `scope`.** If you are polling snapshots in a loop, this is the one you will hit, and a client that only parses JSON 429 bodies will break on it. Parse the status code, not the body. Better: do not poll the snapshot in a loop. That is what the stream is for. A grid snapshot is cached for 100 ms server-side anyway, so polling faster than 10 Hz returns you the same frame twice. --- ## Errors | Status | When | Body | |--------|------|------| | **200** | fine | the payload | | **400** | unknown `format` | JSON, `error` + `hint` | | **403** | wrong credential, no credential, or a host token | JSON, `error` + `hint` | | **404** | unknown session id, or one reaped after its last device left | JSON, `error` + `hint` | | **410** | session ended by its host | JSON, `error` + `hint` | | **429** | a stream limit (JSON, with `Retry-After`) or the flat HTTP rate limit (plain text, no `Retry-After`) | see above | Every JSON error carries an `error` and a `hint`. Every hint restates something already public in this document, so none of them tells an attacker anything it did not already know. **404 against 410 is a real distinction** and worth branching on: ```json { "error": "Session not found", "hint": "Unknown session id, or the session was reaped after its last device left." } ``` ```json { "error": "Session ended", "hint": "This session was ended by its host. Its readKey died with it." } ``` A 404 can mean the session simply has not started yet, so it is worth polling for gently. A 410 is a tombstone: the host ended that session deliberately and it is not coming back under that id. Tombstones last up to 24 hours, after which the id goes back to answering 404. Do not build anything on the tombstone outliving a particular window: the relay keeps a bounded number of them and drops the oldest first, so a busy relay forgets sooner. --- ## The v1 stability promise **Fields get added. They are never removed, and never repurposed.** That is the whole promise, and it is the reason a v2 should rarely be necessary. If you write a consumer against this document today, it keeps working. New fields will appear next to the ones you know about; write your parser so it ignores what it does not recognise, and you will never have to touch it for a relay upgrade. What "never repurposed" rules out is the failure that actually hurts: a field that keeps its name and quietly changes what it means. A consumer cannot detect that. It just starts being subtly wrong on stage. So it does not happen. ### Frozen, by name | Field | Frozen as | |-------|-----------| | `schema` | the payload version integer. `1` today. It changes only if something incompatible ever happens, which is what the rest of this section exists to avoid | | `average` | **the unweighted mean position, forever.** See below | | `centroid` | the energy-weighted mean position | | `count` | dots on the board | | `participantCount` | **every** connected device, screens included. See below | | `crowdCount` | connected devices that can place a dot, screens excluded | | `energy` | mean of the dots' energy values | | `spread` | RMS distance from `centroid`, normalised | | `t` | server wall clock, seconds since the epoch, float | | `ts` (on a position) | when that dot was last moved, same time base as `t` | | `x`, `y` | 0..1, **y screen-down**, 0 is the top and the `yHigh` label | | `res`, `max`, `grid` | grid edge, per-frame peak density, base64 of `res * res` uint8 | | the grid encoding | row major, cell `(x, y)` at byte `y * res + x`, density `(byte / 255) ^ 2 * max` | | `reason` on `end` | the seven values listed above. New reasons may be added; the existing ones keep their meaning | **`average` deserves its own paragraph.** It sits right next to `centroid` and `centroid` is usually the better number, so there is a permanent temptation to "improve" `average` into an energy-weighted mean. That will not happen. `average` shipped in v1 as the plain unweighted mean of the dot positions and it is the plain unweighted mean of the dot positions forever, even though `centroid` sits next to it being the one you probably want. If you want weighting, read `centroid`. If you have a five year old patch reading `average`, it will still be reading the same quantity. **`participantCount` earned the same paragraph, and `crowdCount` is what it bought.** `participantCount` counts every device connected to the session, and the screens count: the host's dashboard, a projector, an embed. That is a bad number to print in front of a room, because an empty room reads 1 or 2 rather than 0. So the relay's own pages stopped using it, and the obvious cleanup was to narrow this field to match them. That is exactly what did not happen. A rig reading `participantCount` to decide whether anyone is connected at all would have started reading 0 for a live session with its screens up, silently, mid-show, with nothing in the payload to notice it by. Instead `crowdCount` was added beside it, which is purely additive: a consumer that never hears about the new field keeps reading the same quantity it always read, and one that wants the room reads one field it did not have before. That is the whole promise working as intended, and it is the shape every future correction takes. `participantCount` is connected devices, and it stays connected devices. ### Also frozen - **The read key stays `pk_`-prefixed**, grants read access and nothing else, and stays distinguishable from a host token. Its length and alphabet may grow, so match on the prefix and treat the rest as opaque. - **The host token is never accepted on `/v1`.** - **`connectionId` never appears on this surface.** There is no per-dot identity here and there will not be one. - **`/v1` paths do not move.** `snapshot` and `stream` stay where they are. ### Not frozen, so read it from the payload These are operational numbers, not contract. They can change, and every one of them travels in the payload so a consumer follows it without an upgrade: | Thing | Read it from | |-------|--------------| | effective grid resolution | `res`, on **every** grid frame | | the resolution ceiling | `maxRes`, in `hello` and the snapshot | | the stream's actual rate | `hz`, in `hello` | | the maximum lifetime | `maxLifetimeSec`, in `hello` | | the heartbeat interval | `heartbeatSec`, in `hello` | | the session's labels and decay | `settings`, in `hello` and the snapshot | | the concurrency and bandwidth limits | the `limit` field on a 429 | Hard-coding 48, or 96, or 1800, is how a consumer breaks on a relay upgrade that broke nothing. **Settings are not pushed.** They ship in `hello` and in every snapshot. The stream carries crowd state rather than configuration. `res` travels with every grid frame, so a decoder follows a resolution change without being told, but if you need live axis labels, re-fetch the snapshot or `GET /sessions/:id` when you want them. --- ## Worked example: driving visuals Python, standard library only, so it runs in TouchDesigner's bundled Python without installing anything. Reads `scalars` at 4 Hz and prints the two numbers most rigs end up driving something with: `energy` and `centroid`. ```python """Read a Pulse stream. Stdlib only: works in TouchDesigner's Python.""" import json import urllib.request RELAY = "https://pulse.krateo.me" def stream(session_id, read_key, fmt="scalars", hz=10, relay=RELAY): """Yield (event_name, payload) for as long as the stream lasts. Returns when the stream ends. Reconnect yourself: the relay closes every stream after 30 minutes and a frame is absolute state, so there is nothing to catch up on. """ url = f"{relay}/v1/sessions/{session_id}/stream?format={fmt}&hz={hz}" req = urllib.request.Request(url, headers={ # Header form, not ?key=, so the key stays out of proxy logs. "Authorization": f"Bearer {read_key}", # No gzip: the stdlib will not decompress a stream for us. "Accept-Encoding": "identity", }) event = "message" with urllib.request.urlopen(req) as resp: for raw in resp: line = raw.decode("utf-8").rstrip("\n") if line.startswith(":"): # heartbeat comment, ignore continue if line.startswith("event: "): event = line[7:] elif line.startswith("data: "): yield event, json.loads(line[6:]) elif line == "": event = "message" for event, data in stream("d70f0319", "pk_0000000000000000000000fake", hz=4): if event == "hello": print(f"connected: {data['hz']} Hz, {data['format']}, " f"closes after {data['maxLifetimeSec']} s") elif event == "frame": s = data["scalars"] if s["count"] == 0: print("empty board") continue c = s["centroid"] # energy 0.2..1.0 drives brightness; centroid drives position. # y is screen-down, so flip it if your tool's origin is bottom-left. print(f"energy {s['energy']:.2f} spread {s['spread']:.2f} " f"centroid x {c['x']:.3f} y {c['y']:.3f} " f"({s['count']} dots, {data['crowdCount']} in the room)") elif event == "end": print(f"stream closed by the relay: {data['reason']}") break ``` Real output: ``` connected: 4 Hz, scalars, closes after 1800 s energy 0.78 spread 0.45 centroid x 0.363 y 0.639 (5 dots, 5 in the room) energy 0.78 spread 0.45 centroid x 0.363 y 0.639 (5 dots, 5 in the room) energy 0.78 spread 0.45 centroid x 0.363 y 0.639 (5 dots, 5 in the room) ``` Wiring it into TouchDesigner: put `stream()` in a Text DAT, run the loop in a thread from an Execute DAT, and write `energy`, `centroid.x` and `1 - centroid.y` into a Constant CHOP. The flip on `y` is the boundary crossing from screen-down to TouchDesigner's bottom-left origin, and it belongs in exactly one place. For a texture rather than three numbers, switch to `format=grid` and feed the decoded field into a Script TOP, remembering to size it from `res` on the frame. --- ## Worked example: in a browser `EventSource` plus a canvas. This is the code path where the `?key=` form is not optional, because `EventSource` cannot set headers. ```js const RELAY = 'https://pulse.krateo.me'; const SESSION = 'd70f0319'; const KEY = 'pk_0000000000000000000000fake'; const ctx = document.getElementById('c').getContext('2d'); let img = null; // EventSource cannot set headers, so the key goes in the query string here. const es = new EventSource( `${RELAY}/v1/sessions/${SESSION}/stream?format=grid&hz=10&key=${KEY}` ); es.addEventListener('hello', (e) => { const h = JSON.parse(e.data); console.log(`${h.format} at ${h.hz} Hz, res ${h.res} (ceiling ${h.maxRes}), ` + `host is at ${h.settings.gridResolution}`); }); es.addEventListener('frame', (e) => { const f = JSON.parse(e.data); const res = f.res; // Size from the frame, not from settings: the host can change it mid-session. if (!img || img.width !== res) img = ctx.createImageData(res, res); const bin = atob(f.grid); for (let i = 0; i < res * res; i++) { const c = bin.charCodeAt(i) / 255; const norm = c * c; // 0..1, peak of this frame is 1 const v = Math.round(255 * Math.pow(norm, 0.7)); // gamma, to taste img.data[i * 4] = v; img.data[i * 4 + 1] = Math.round(v * 0.4); img.data[i * 4 + 2] = Math.round(v * 0.9); img.data[i * 4 + 3] = 255; } // Row 0 of ImageData is the top, and y = 0 is the top of the board, so these // already agree. Flip only if your renderer's origin is bottom-left. createImageBitmap(img).then((bmp) => ctx.drawImage(bmp, 0, 0, 256, 256)); }); es.addEventListener('end', (e) => { const { reason } = JSON.parse(e.data); console.log('relay closed the stream:', reason); // These three are not coming back. Stop EventSource reconnecting into a 403. if (['session_ended', 'key_revoked', 'session_idle'].includes(reason)) es.close(); }); es.onerror = () => console.log('transport dropped, EventSource will retry in 2 s'); ``` Run against a live session this logs ``` grid at 10 Hz, res 48 (ceiling 96), host is at 48 ``` and paints 10 frames a second. Note what you do **not** have to write: no reconnect logic, no keepalive, no catch-up. `retry: 500` and the absence of a replay buffer between them mean a drop costs you one `hello`. Note also `norm` rather than absolute density. For a texture that always fills its range, use `(byte/255)^2`. Multiply by `f.max` when you want brightness to track how busy the room actually is, and guard `f.max === 0` for an empty board. --- ## Worked example: is this thing on You are in a venue, the visuals are not moving, and you have 30 seconds. ```bash export RELAY=https://pulse.krateo.me export SID= export KEY= # 1. Is the relay up at all? No credential needed. curl -s "$RELAY/health" # ok # 2. Does the session exist, and is anyone in the room? # crowdCount is the room. participantCount is every device including the # host's dashboard and any projector, so it is 1 or 2 on an empty room. # No credential needed, and it carries no secret. curl -s "$RELAY/sessions/$SID" # {"sessionId":"d70f0319","participantCount":6,"crowdCount":5,"settings":{...}} # 3. Is my key good, and is there anything on the board? curl -s "$RELAY/v1/sessions/$SID/snapshot?format=scalars" \ -H "Authorization: Bearer $KEY" # 4. Is it actually moving? Watch three seconds of frames. curl -N --max-time 3 "$RELAY/v1/sessions/$SID/stream?format=scalars&hz=2&key=$KEY" ``` Reading the results: | What you see | What it means | |--------------|---------------| | step 1 fails | the relay is down or you cannot reach it. Not your key, not your patch | | step 2 says `404` | wrong session id, or the session was reaped after the last phone left | | step 2 says `410` | the host ended this session. Get a new id and a new key | | step 3 says `403` | wrong key, a key the host has since regenerated, a key from before the session restarted, or you pasted the host token | | step 2 shows `crowdCount: 0` | nobody has scanned yet. If `participantCount` is 1 or 2, that is the host's dashboard and the projector, and the session is fine | | step 3 works but `count` is 0 | the board is genuinely empty. Nobody has voted, or decay took the dots. Not an API problem | | step 3 works, step 4 gives nothing | check whatever is between you and the relay for response buffering | | step 4 shows frames but `t` barely advances | you are being buffered somewhere; frames should arrive at your `hz` | | step 4 shows identical frames | correct if the room is still. The cadence is fixed and a frame is absolute state, not a change notification | | `429` plain text `rate limit exceeded` | you are making more than 100 requests a second. Stop polling | | `429` JSON with a `scope` | a stream limit. Read `hint`; it names the number and usually what would fit | One more, for when the visuals look right but upside down: you have the `y` flip wrong. `y = 0` is the **top**, which is the `yHigh` label. See [Axes, and the one trap](#axes-and-the-one-trap). --- ## What is deliberately not here **`GET /v1/sessions/:id` is a 404.** Session info lives at `GET /sessions/:id`, without a `/v1` prefix and without a credential. It returns `sessionId`, `participantCount`, `crowdCount` and `settings`, with the two counts meaning exactly what they mean on `/v1`. It carries no credential and no fingerprint of one, which is why it needs no auth. **The unversioned endpoints are not aliased under `/v1`.** `POST /sessions`, `DELETE /sessions/:id`, `POST /sessions/:id/readkey` and the rest are the relay's own control plane, used by the host page, the crowd page and the desktop app, all of which ship in the same commit as the relay. They are not part of this stability promise. Nothing about `/v1` implies there will one day be a `/v2` of `POST /sessions`. **The WebSocket protocol is not documented here.** It exists, it is what the crowd and the host use, and it is the door for full-protocol clients that also send. If you only consume, you want this API: it reconnects on its own, survives proxies that eat upgrades, and is readable with one command. **There is no write path on `/v1`.** A read key subscribes and does nothing else. Placing a dot, changing settings and clearing the board are the host's and the crowd's, and they are not reachable with this credential by design. **There is no historical data.** The relay holds sessions in memory and no frame is ever stored. If you want a recording of a room, record the stream yourself; every frame carries its own `schema` and `t` precisely so that a log of frames is replayable on its own. If what you actually want is the board in a report deck afterwards, the host dashboard exports a CSV and you need no credential for it, and a screenshot covers the picture: see `PRESENTING.md`. **Nothing here is needed to show the board in a room.** Speakers putting Pulse on a projector next to their slides want `/present/:id` and no API at all. That is `PRESENTING.md`, and it is the right link to hand anyone who asks how to embed Pulse in PowerPoint or Keynote.