diff --git a/examples/package.json b/examples/package.json
index 2bc7219d0f..b8a58f6e4e 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -48,6 +48,7 @@
"pub-sub-rewind/react",
"pub-sub-rewind/javascript",
"pub-sub-message-annotations/javascript",
+ "pub-sub-live-voting/javascript",
"spaces-avatar-stack/react",
"spaces-avatar-stack/javascript",
"spaces-component-locking/react",
@@ -100,6 +101,7 @@
"pub-sub-rewind-javascript": "yarn workspace pub-sub-rewind-javascript dev",
"pub-sub-rewind-react": "yarn workspace pub-sub-rewind-react dev",
"pub-sub-message-annotations-javascript": "yarn workspace pub-sub-message-annotations-javascript dev",
+ "pub-sub-live-voting-javascript": "yarn workspace pub-sub-live-voting-javascript dev",
"spaces-avatar-stack-javascript": "yarn workspace spaces-avatar-stack-javascript dev",
"spaces-avatar-stack-react": "yarn workspace spaces-avatar-stack-react dev",
"spaces-component-locking-javascript": "yarn workspace spaces-component-locking-javascript dev",
@@ -119,6 +121,7 @@
"lodash": "^4.18.1",
"minifaker": "^1.34.1",
"nanoid": "^5.0.7",
+ "qrcode": "^1.5.4",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.4.0",
@@ -130,6 +133,7 @@
"@tailwindcss/postcss": "^4.0.14",
"@types/express": "^5.0.0",
"@types/node": "^20",
+ "@types/qrcode": "^1.5.5",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uikit": "^3.7.0",
diff --git a/examples/pub-sub-live-voting/javascript/.gitignore b/examples/pub-sub-live-voting/javascript/.gitignore
new file mode 100644
index 0000000000..804bd4a4ae
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+dist
+*.local
diff --git a/examples/pub-sub-live-voting/javascript/README.md b/examples/pub-sub-live-voting/javascript/README.md
new file mode 100644
index 0000000000..c5d5535c68
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/README.md
@@ -0,0 +1,133 @@
+# Build live voting with message annotations
+
+This is a live-voting app: an admin runs a show of polls, an audience votes
+from their phones, and a big screen shows results updating in realtime. The
+voter pane and presenter pane on the left are both live: cast a vote and watch
+the presenter's heatmap and vote bubbles react instantly.
+
+There's a lot of different ways you can implement this using Ably. This example
+shows one possible pattern, using
+[**annotations**](/docs/messages/annotations) for votes. A poll
+is a regular Ably message, and each vote is an annotation attached to that
+message. Ably aggregates the annotations into a summary, attached to the
+original poll message, and delivers it to subscribers.
+
+Votes are never stored in a database. They live entirely as annotations on the channel, and Ably does the aggregation. Your own server's job is narrow: it authenticates clients and it hands out the poll definitions. Anything expensive and latency-sensitive is done by Ably.
+
+## Why annotations?
+
+The simplest way you could imagine implementing a live voting system with Ably would be to have each vote be a message. The presenter would subscribe to the channel and keep a running tally in memory, and re-render when needed. This works for a demo, but has drawbacks. Everyone gets the same set of messages, so every voter sees everyone's vote unnecessarily; and nothing constrains one client to only have one vote without custom deduplication logic in any client that wants to count votes, which could get increasingly expensive as the set of voters grows. There's no server-side aggregation, so a phone that joins late, or a presenter view that refreshes, has missed the votes that already happened and has to reconstruct the tally from history. (And imagine if you do have a hundred thousand voters, you'd have to paginate through tens of thousands of pages of history to get the vote count).
+
+ [LiveObjects](/docs/liveobjects) is another possibility, which solves some of these problems. You could model the tally as a [`LiveCounter`](https://ably.com/docs/liveobjects/counter) per option; a synchronised, conflict-free counter on the channel. It's a lovely primitive. But there's no built-in one-vote-per-client constraint: any client can call `increment` as often as it likes. To stop ballot-stuffing you'd need a separate [`LiveMap`](https://ably.com/docs/liveobjects/map) of `clientId → vote` and check it before incrementing, but there's no atomic check-and-increment across the two, so a double-counting is still possible. And there's no fine-grained capabilities; any client with the ability to increment one livecounter can arbitrarily mutate the whole state.
+
+So, [annotations](/docs/messages/annotations), which have three big advantages:
+
+- Summaries and raw events are two separate streams, and different clients want different ones. A voter's phone only needs the *summary*: a single compact, rolled-up object, with aggregated information on all the annotations that have been contributed. The exact information in the summary depends on the annotation's [aggregation type](/docs/messages/annotations#aggregation), and there are several choices. With very popular polls, each voter only gets a periodically-rolled-up summary update, not one event per vote. But the presenter screen does want to see individual annotation events, so it can animate a bubble for each one (and label it with the voter who cast it), and it can opt into that higher-volume stream separately, with the [`ANNOTATION_SUBSCRIBE` channel mode](https://ably.com/docs/messages/annotations#individual-annotations) and an `annotations.subscribe()` listener. Same underlying data is exposed in two ways, each appropriate to the view that needs it.
+
+- Capabilities for least-privilege. Because a vote is an annotation, a voter's [token](/docs/auth/capabilities) can grant *only* the `annotation-publish` capability on the channel (and `message-subscribe`). A voter can cast votes and do literally nothing else: it can't publish poll messages (which are 'full' messages, not annotations), and it can't subscribe to other voters' raw annotations. It can't tamper with the poll or snoop on who voted for what. The presenter gets `annotation-subscribe`; the admin gets full message publish rights. See the per-role capabilities in [`server/src/server.ts`](https://github.com/ably-demos/live-voting-with-annotations/blob/main/server/src/server.ts).
+
+- One person, one vote, enforced by Ably. Votes use the `unique` [aggregation type](/docs/messages/annotations#aggregation). In `unique` mode Ably keeps at most one annotation per `clientId`, and switching to another option *moves* that client's vote rather than adding a second one. As long as your auth server vets users and assigns each one a unique `clientId`, a careful choice of aggregation type gives you the vote semantics you need.
+
+## Roles
+
+The app has three roles.
+
+- The *voter* is the phone in your hand: pick an option (a list, or a d-pad for something more playful), and watch the live percentages. It only ever publishes annotations and reads the summary.
+- The *presenter* is the big screen. It reads the summary to draw the bar chart or d-pad heatmap (with a star badge on the current leader), and subscribes to individual annotations to pop a bubble for each vote and suggestion as it lands.
+- The *admin* is the operator console, the only role that publishes poll messages. It walks through a show of polls, opens and closes voting, and (because it can both publish and subscribe) shows the organiser a live view of what's happening.
+
+## How it works
+
+1. The admin starts a poll by publishing a message:
+
+ ```javascript
+ await channel.publish('poll', { pollId, question, type, options });
+ ```
+
+2. A voter attaches a `vote:unique.v1` annotation to that message's `serial`,
+ naming the chosen option:
+
+ ```javascript
+ await channel.annotations.publish(pollSerial, {
+ type: 'vote:unique.v1',
+ name: optionId,
+ });
+ ```
+
+3. Ably aggregates the votes and delivers a summary on the poll message. Voters
+ read it to render live percentages:
+
+ ```javascript
+ channel.subscribe((message) => {
+ const summary = message.annotations?.summary?.['vote:unique.v1'];
+ // summary[optionId].total === votes for that option
+ });
+ ```
+
+4. The presenter additionally subscribes to the individual events for its vote
+ and suggestion bubbles:
+
+ ```javascript
+ channel.annotations.subscribe('vote:unique.v1', (annotation) => {
+ // one event per vote — annotation.name is the option, annotation.clientId the voter
+ });
+ ```
+
+That annotation type string, `vote:unique.v1`, follows the `namespace:summarization.version` convention; `unique` is one of [five aggregation types](/docs/messages/annotations#aggregation) (`unique`, `distinct`, `multiple`, `total`, `flag`), each rolling up the same raw annotations a different way.
+
+The channel [is attached with `rewind: 1`](/docs/channels/options/rewind), so a phone that joins (or refreshes) mid-poll immediately receives the current poll message and its latest summary. Late joiners are caught up without the admin republishing anything.
+
+## Unaggregated annotations
+
+Open-ended "suggest" polls (where voters type free text instead of picking an
+option) use a second annotation type, `suggestion`, with no aggregation suffix.
+Votes need a running tally, so they use `unique` and are read from the summary.
+A suggestion is a one-off piece of text that floats across the presenter once
+and is gone, with nothing to count. Since they are unaggregated, they are
+received by anyone subscribing to annotations, and they are retrievable through
+anyone using
+[annotations.get()](/docs/messages/annotations#retrieve) to
+retrieve a list of annotations for a given message, but they don't result in a
+new message summary being generated.
+
+## Server-side batching
+
+For high volume usecases, where individual votes might (say) overwhelm the
+presenter view's message rate limits, you can turn on [server-side
+batching](/docs/messages/batch) for the `voting` channel namespace so Ably
+groups messages over a short interval (for example 100ms) before fanning them
+out. This keeps cost and rate-limits in check during surges with only a small,
+configurable delay. Create the rule in your app settings, or via the Control
+API / CLI:
+
+```shell
+ably apps rules create --name "voting" --batching-enabled --batching-interval 100
+```
+
+## Required channel rule
+
+Annotations require the *Message annotations, updates, deletes, and appends*
+rule to be enabled on the channel or namespace. Enable it for the `voting`
+namespace before running this against your own app.
+
+## Getting started
+
+The live demo above is a cut-down stub of the full demo, intended to be run entirely in the browser, to demonstrate the voting page. If you want to try it out yourself, the real example, with a proper auth server and the admin/voter/presenter split and token authentication, lives at [ably-demos/live-voting-with-annotations](https://github.com/ably-demos/live-voting-with-annotations).
+
+Its README has the full setup, but in short: give the server an Ably API key and
+an admin password, pick the static (`SHOWS_FILE`) or Postgres poll store, then
+run the server and the client. The default view is the voter; `?role=admin`
+drives the show and `?role=presenter` is the big screen, and the admin shows a QR
+code that points voters at the right session.
+
+The client never sees an API key — it calls the server's `/auth` endpoint for
+short-lived, role-scoped tokens. The hosted demo above has no backend, so it uses
+a raw key embedded in the page; that's just for the demo and should never be done
+in a real app.
+
+## Related
+
+- [Message annotations](/docs/messages/annotations) — the full feature reference.
+- [Message annotations example](/examples/pub-sub-message-annotations) — a
+ lower-level tour of all five aggregation types.
+- [Message batching](/docs/messages/batch) — scaling high-throughput channels.
diff --git a/examples/pub-sub-live-voting/javascript/data/demo-shows.json b/examples/pub-sub-live-voting/javascript/data/demo-shows.json
new file mode 100644
index 0000000000..ce877c4447
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/data/demo-shows.json
@@ -0,0 +1,38 @@
+{
+ "shows": [
+ {
+ "id": 1,
+ "name": "Ably Live Voting demo",
+ "polls": [
+ {
+ "id": 1,
+ "question": "Which real-time feature do you reach for most?",
+ "type": "list",
+ "options": [
+ { "id": 101, "label": "Pub/Sub channels" },
+ { "id": 102, "label": "Presence" },
+ { "id": 103, "label": "Message history" },
+ { "id": 104, "label": "Push notifications" }
+ ]
+ },
+ {
+ "id": 2,
+ "question": "Rate this demo with the d-pad",
+ "type": "dpad",
+ "options": [
+ { "id": 201, "label": "Love it", "direction": "up" },
+ { "id": 202, "label": "It's neat", "direction": "right" },
+ { "id": 203, "label": "Meh", "direction": "down" },
+ { "id": 204, "label": "Confused", "direction": "left" }
+ ]
+ },
+ {
+ "id": 3,
+ "question": "What should we build with Ably next?",
+ "type": "suggest",
+ "options": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql
new file mode 100644
index 0000000000..0eaa99d8b7
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql
@@ -0,0 +1,7 @@
+ALTER TABLE polls
+ ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'list'
+ CHECK (type IN ('list', 'dpad'));
+
+ALTER TABLE poll_options
+ ADD COLUMN IF NOT EXISTS direction TEXT
+ CHECK (direction IS NULL OR direction IN ('up', 'right', 'down', 'left'));
diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql
new file mode 100644
index 0000000000..d2d180a737
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql
@@ -0,0 +1,3 @@
+ALTER TABLE polls DROP CONSTRAINT IF EXISTS polls_type_check;
+ALTER TABLE polls ADD CONSTRAINT polls_type_check
+ CHECK (type IN ('list', 'dpad', 'suggest'));
diff --git a/examples/pub-sub-live-voting/javascript/database/seed.sql b/examples/pub-sub-live-voting/javascript/database/seed.sql
new file mode 100644
index 0000000000..633bd85c27
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/seed.sql
@@ -0,0 +1,18 @@
+-- Optional sample data for the Postgres store. The d-pad and suggest poll
+-- types make the presenter view more expressive than list polls alone.
+INSERT INTO shows (name) VALUES ('Ably Live Voting demo');
+
+INSERT INTO polls (show_id, question, type, sort_order) VALUES
+ (1, 'Which real-time feature do you reach for most?', 'list', 0),
+ (1, 'Rate this demo with the d-pad', 'dpad', 1),
+ (1, 'What should we build with Ably next?', 'suggest', 2);
+
+INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES
+ (1, 'Pub/Sub channels', NULL, 0),
+ (1, 'Presence', NULL, 1),
+ (1, 'Message history', NULL, 2),
+ (1, 'Push notifications', NULL, 3),
+ (2, 'Love it', 'up', 0),
+ (2, 'It''s neat', 'right', 1),
+ (2, 'Meh', 'down', 2),
+ (2, 'Confused', 'left', 3);
diff --git a/examples/pub-sub-live-voting/javascript/database/setup.sql b/examples/pub-sub-live-voting/javascript/database/setup.sql
new file mode 100644
index 0000000000..d71ade40bc
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/database/setup.sql
@@ -0,0 +1,24 @@
+CREATE TABLE shows (
+ id SERIAL PRIMARY KEY,
+ name TEXT NOT NULL
+);
+
+CREATE TABLE polls (
+ id SERIAL PRIMARY KEY,
+ show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
+ question TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ type TEXT NOT NULL DEFAULT 'list'
+ CHECK (type IN ('list', 'dpad', 'suggest'))
+);
+
+CREATE TABLE poll_options (
+ id SERIAL PRIMARY KEY,
+ poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
+ label TEXT NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ direction TEXT
+ CHECK (direction IS NULL OR direction IN (
+ 'up', 'right', 'down', 'left'
+ ))
+);
diff --git a/examples/pub-sub-live-voting/javascript/index.html b/examples/pub-sub-live-voting/javascript/index.html
new file mode 100644
index 0000000000..cf21dc306f
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+ Ably Live Voting
+
+
+
+
+
+
+
+
diff --git a/examples/pub-sub-live-voting/javascript/package.json b/examples/pub-sub-live-voting/javascript/package.json
new file mode 100644
index 0000000000..3425e78671
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "pub-sub-live-voting-javascript",
+ "version": "1.0.0",
+ "main": "index.js",
+ "license": "MIT",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ }
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/.env.example b/examples/pub-sub-live-voting/javascript/server/.env.example
new file mode 100644
index 0000000000..573433b69c
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/.env.example
@@ -0,0 +1,14 @@
+# Your Ably API key, in "keyName:keySecret" form. Used to sign role-scoped JWTs.
+ABLY_API_KEY=
+
+# Password the admin console must supply to drive a show.
+ADMIN_PASSWORD=changeme
+
+# Read polls from a static JSON file (no database). Omit to use Postgres.
+# Path is resolved relative to the server's working directory.
+SHOWS_FILE=../data/demo-shows.json
+
+# Postgres connection string (only used when SHOWS_FILE is unset).
+# DATABASE_URL=postgresql://localhost/ably_voting
+
+# PORT=3000
diff --git a/examples/pub-sub-live-voting/javascript/server/package.json b/examples/pub-sub-live-voting/javascript/server/package.json
new file mode 100644
index 0000000000..eb1e196fd4
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "pub-sub-live-voting-server",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "src/server.ts",
+ "license": "MIT",
+ "scripts": {
+ "dev": "tsx watch src/server.ts",
+ "start": "tsx src/server.ts"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "express": "^4.21.0",
+ "jsonwebtoken": "^9.0.0",
+ "pg": "^8.20.0"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.17",
+ "@types/express": "^4.17.21",
+ "@types/jsonwebtoken": "^9.0.0",
+ "@types/node": "^20",
+ "@types/pg": "^8.20.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/src/db.ts b/examples/pub-sub-live-voting/javascript/server/src/db.ts
new file mode 100644
index 0000000000..47ac7be578
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/db.ts
@@ -0,0 +1,177 @@
+import pg from 'pg';
+import type { OptionInput, ShowStore } from './showStore.js';
+
+// Lazily constructed so that static (SHOWS_FILE) deployments never open — or
+// even configure — a database connection.
+let _pool: pg.Pool | null = null;
+function pool(): pg.Pool {
+ if (!_pool) {
+ _pool = new pg.Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql://localhost/ably_voting',
+ });
+ }
+ return _pool;
+}
+
+async function getShows() {
+ const { rows } = await pool().query(`
+ SELECT s.id, s.name, COUNT(p.id)::int AS poll_count
+ FROM shows s
+ LEFT JOIN polls p ON p.show_id = s.id
+ GROUP BY s.id
+ ORDER BY s.id
+ `);
+ return rows;
+}
+
+async function createShow(name: string) {
+ const { rows } = await pool().query(
+ 'INSERT INTO shows (name) VALUES ($1) RETURNING id, name',
+ [name],
+ );
+ return rows[0];
+}
+
+async function updateShow(id: number, name: string) {
+ const { rowCount } = await pool().query(
+ 'UPDATE shows SET name = $1 WHERE id = $2',
+ [name, id],
+ );
+ return rowCount! > 0;
+}
+
+async function deleteShow(id: number) {
+ const { rowCount } = await pool().query('DELETE FROM shows WHERE id = $1', [id]);
+ return rowCount! > 0;
+}
+
+async function getShowPolls(showId: number) {
+ const showResult = await pool().query('SELECT id, name FROM shows WHERE id = $1', [showId]);
+ if (showResult.rows.length === 0) return null;
+
+ const pollRows = await pool().query(
+ 'SELECT id, question, type, sort_order FROM polls WHERE show_id = $1 ORDER BY sort_order, id',
+ [showId],
+ );
+
+ const optionRows = await pool().query(
+ `SELECT po.id, po.poll_id, po.label, po.direction, po.sort_order
+ FROM poll_options po
+ JOIN polls p ON p.id = po.poll_id
+ WHERE p.show_id = $1
+ ORDER BY po.sort_order, po.id`,
+ [showId],
+ );
+
+ const optionsByPoll = new Map>();
+ for (const opt of optionRows.rows) {
+ if (!optionsByPoll.has(opt.poll_id)) optionsByPoll.set(opt.poll_id, []);
+ optionsByPoll.get(opt.poll_id)!.push({
+ id: opt.id, label: opt.label, direction: opt.direction, sort_order: opt.sort_order,
+ });
+ }
+
+ const polls = pollRows.rows.map((p) => ({
+ id: p.id,
+ question: p.question,
+ type: p.type,
+ sort_order: p.sort_order,
+ options: optionsByPoll.get(p.id) ?? [],
+ }));
+
+ return { show: showResult.rows[0], polls };
+}
+
+async function createPoll(showId: number, type: string, question: string, options: OptionInput[]) {
+ const client = await pool().connect();
+ try {
+ await client.query('BEGIN');
+
+ const maxOrder = await client.query(
+ 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM polls WHERE show_id = $1',
+ [showId],
+ );
+
+ const pollResult = await client.query(
+ 'INSERT INTO polls (show_id, question, type, sort_order) VALUES ($1, $2, $3, $4) RETURNING id',
+ [showId, question, type, maxOrder.rows[0].next],
+ );
+ const pollId = pollResult.rows[0].id;
+
+ for (let i = 0; i < options.length; i++) {
+ await client.query(
+ 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)',
+ [pollId, options[i].label, options[i].direction ?? null, i],
+ );
+ }
+
+ await client.query('COMMIT');
+ return pollId;
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+async function updatePoll(pollId: number, type: string, question: string, options: OptionInput[]) {
+ const client = await pool().connect();
+ try {
+ await client.query('BEGIN');
+
+ await client.query('UPDATE polls SET question = $1, type = $2 WHERE id = $3', [question, type, pollId]);
+ await client.query('DELETE FROM poll_options WHERE poll_id = $1', [pollId]);
+
+ for (let i = 0; i < options.length; i++) {
+ await client.query(
+ 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)',
+ [pollId, options[i].label, options[i].direction ?? null, i],
+ );
+ }
+
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+async function deletePoll(pollId: number) {
+ const { rowCount } = await pool().query('DELETE FROM polls WHERE id = $1', [pollId]);
+ return rowCount! > 0;
+}
+
+async function reorderPolls(showId: number, pollIds: number[]) {
+ const client = await pool().connect();
+ try {
+ await client.query('BEGIN');
+ for (let i = 0; i < pollIds.length; i++) {
+ await client.query(
+ 'UPDATE polls SET sort_order = $1 WHERE id = $2 AND show_id = $3',
+ [i, pollIds[i], showId],
+ );
+ }
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+export const pgStore: ShowStore = {
+ readOnly: false,
+ getShows,
+ getShowPolls,
+ createShow,
+ updateShow,
+ deleteShow,
+ createPoll,
+ updatePoll,
+ deletePoll,
+ reorderPolls,
+};
diff --git a/examples/pub-sub-live-voting/javascript/server/src/server.ts b/examples/pub-sub-live-voting/javascript/server/src/server.ts
new file mode 100644
index 0000000000..a0b586fb57
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/server.ts
@@ -0,0 +1,326 @@
+import express from 'express';
+import cors from 'cors';
+import jwt from 'jsonwebtoken';
+import path from 'path';
+import { existsSync } from 'fs';
+import { fileURLToPath } from 'url';
+import type { OptionInput, ShowStore } from './showStore.js';
+import { pgStore } from './db.js';
+import { createStaticStore } from './staticStore.js';
+
+// The backend has two jobs:
+// 1. Issue short-lived, role-scoped Ably tokens (GET /auth). This is the
+// important bit for the tutorial — a voter token can publish annotations
+// and nothing else.
+// 2. Serve the poll definitions to the admin (GET /api/shows...), from either
+// a static JSON file (SHOWS_FILE) or Postgres.
+// It never touches votes — those live entirely as Ably annotations.
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const ABLY_API_KEY = process.env.ABLY_API_KEY;
+if (!ABLY_API_KEY) {
+ console.error('ABLY_API_KEY environment variable is required');
+ process.exit(1);
+}
+
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
+if (!ADMIN_PASSWORD) {
+ console.error('ADMIN_PASSWORD environment variable is required');
+ process.exit(1);
+}
+
+const [keyName, keySecret] = ABLY_API_KEY.split(':');
+
+// Data source: a JSON file (read-only, no database) when SHOWS_FILE is set,
+// otherwise Postgres. Resolved relative to the working directory.
+const SHOWS_FILE = process.env.SHOWS_FILE;
+let store: ShowStore;
+try {
+ store = SHOWS_FILE ? createStaticStore(path.resolve(SHOWS_FILE)) : pgStore;
+} catch (err: any) {
+ console.error(err.message);
+ process.exit(1);
+}
+console.log(store.readOnly
+ ? `Using read-only static show data from ${SHOWS_FILE}`
+ : 'Using Postgres show store');
+
+const app = express();
+
+// The client is a separate Vite app. In production it can be served from the
+// same origin (see the static block below); in development it runs on Vite's
+// dev server and reaches the API through Vite's proxy. CORS keeps direct
+// cross-origin calls working too.
+app.use(cors({ origin: true, methods: ['GET', 'POST', 'PUT', 'DELETE'] }));
+app.use(express.json());
+
+function fail(res: express.Response, label: string, err: any) {
+ console.error(`${label} failed:`, err);
+ const message = err?.message || err?.code || String(err);
+ res.status(500).json({ error: message });
+}
+
+// Basic auth middleware for admin routes. Only the password half is checked —
+// the client sends `Basic base64("admin:")`.
+function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
+ const auth = req.headers.authorization;
+ if (!auth?.startsWith('Basic ')) {
+ res.set('WWW-Authenticate', 'Basic realm="Admin"');
+ res.status(401).send('Authentication required');
+ return;
+ }
+ const decoded = Buffer.from(auth.slice(6), 'base64').toString();
+ const password = decoded.split(':').slice(1).join(':');
+ if (password !== ADMIN_PASSWORD) {
+ res.set('WWW-Authenticate', 'Basic realm="Admin"');
+ res.status(401).send('Invalid password');
+ return;
+ }
+ next();
+}
+
+// Reject writes when the active store is read-only (static demo data).
+function requireWritable(_req: express.Request, res: express.Response, next: express.NextFunction) {
+ if (store.readOnly) {
+ res.status(403).json({ error: 'This is a read-only demo — editing is disabled.' });
+ return;
+ }
+ next();
+}
+
+// Lets the admin client discover whether editing is available (static vs db).
+app.get('/api/config', (_req, res) => {
+ res.json({ readOnly: store.readOnly });
+});
+
+// Admin API — all behind basic auth
+app.get('/api/shows', requireAdmin, async (_req, res) => {
+ try {
+ res.json(await store.getShows());
+ } catch (err: any) {
+ fail(res, 'GET /api/shows', err);
+ }
+});
+
+app.post('/api/shows', requireAdmin, requireWritable, async (req, res) => {
+ const { name } = req.body;
+ if (!name) { res.status(400).json({ error: 'Missing name' }); return; }
+ try {
+ res.json(await store.createShow(name));
+ } catch (err: any) {
+ fail(res, 'POST /api/shows', err);
+ }
+});
+
+app.put('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => {
+ const { name } = req.body;
+ if (!name) { res.status(400).json({ error: 'Missing name' }); return; }
+ try {
+ const ok = await store.updateShow(Number(req.params.id), name);
+ if (!ok) { res.status(404).json({ error: 'Not found' }); return; }
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `PUT /api/shows/${req.params.id}`, err);
+ }
+});
+
+app.delete('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => {
+ try {
+ const ok = await store.deleteShow(Number(req.params.id));
+ if (!ok) { res.status(404).json({ error: 'Not found' }); return; }
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `DELETE /api/shows/${req.params.id}`, err);
+ }
+});
+
+// Polls within a show — read endpoint is public (presenter needs it)
+app.get('/api/shows/:showId/polls', async (req, res) => {
+ try {
+ const result = await store.getShowPolls(Number(req.params.showId));
+ if (!result) { res.status(404).json({ error: 'Show not found' }); return; }
+ // Convert numeric option IDs to strings for Ably annotation compatibility
+ const polls = result.polls.map((p) => ({
+ ...p,
+ options: p.options.map((o) => ({
+ id: String(o.id),
+ label: o.label,
+ ...(o.direction ? { direction: o.direction } : {}),
+ })),
+ }));
+ res.json({ show: result.show, polls });
+ } catch (err: any) {
+ fail(res, `GET /api/shows/${req.params.showId}/polls`, err);
+ }
+});
+
+const DPAD_SLOTS = new Set(['up', 'right', 'down', 'left']);
+
+const VALID_TYPES = new Set(['list', 'dpad', 'suggest']);
+const ALL_SLOTS = new Set(DPAD_SLOTS);
+
+function validatePollInput(body: any): { type: string; question: string; options: OptionInput[] } | string {
+ const { question } = body;
+ const type = body.type ?? 'list';
+ if (!VALID_TYPES.has(type)) return 'Invalid poll type';
+ if (!question || typeof question !== 'string') return 'Missing question';
+
+ if (type === 'suggest') {
+ return { type, question, options: [] };
+ }
+
+ const options = body.options;
+ if (!Array.isArray(options) || options.length === 0) return 'Missing options';
+
+ const normalized: OptionInput[] = [];
+ const seenDirections = new Set();
+ for (const o of options) {
+ if (typeof o === 'string') {
+ normalized.push({ label: o });
+ } else if (o && typeof o.label === 'string') {
+ const direction = o.direction ?? null;
+ if (direction !== null) {
+ if (!ALL_SLOTS.has(direction)) return `Invalid direction: ${direction}`;
+ if (seenDirections.has(direction)) return `Duplicate direction: ${direction}`;
+ seenDirections.add(direction);
+ }
+ normalized.push({ label: o.label, direction });
+ } else {
+ return 'Invalid option entry';
+ }
+ }
+
+ if (type === 'dpad') {
+ if (normalized.some((o) => !o.direction)) return 'Every d-pad option needs a slot';
+ if (normalized.some((o) => o.direction && !DPAD_SLOTS.has(o.direction))) {
+ return 'Invalid slot for d-pad poll';
+ }
+ if (normalized.length > DPAD_SLOTS.size) return `A d-pad poll has at most ${DPAD_SLOTS.size} options`;
+ }
+
+ return { type, question, options: normalized };
+}
+
+app.post('/api/shows/:showId/polls', requireAdmin, requireWritable, async (req, res) => {
+ const parsed = validatePollInput(req.body);
+ if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; }
+ try {
+ const pollId = await store.createPoll(Number(req.params.showId), parsed.type, parsed.question, parsed.options);
+ res.json({ id: pollId });
+ } catch (err: any) {
+ fail(res, `POST /api/shows/${req.params.showId}/polls`, err);
+ }
+});
+
+app.put('/api/shows/:showId/polls/reorder', requireAdmin, requireWritable, async (req, res) => {
+ const { order } = req.body;
+ if (!Array.isArray(order)) {
+ res.status(400).json({ error: 'Missing order array' });
+ return;
+ }
+ try {
+ await store.reorderPolls(Number(req.params.showId), order);
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `PUT /api/shows/${req.params.showId}/polls/reorder`, err);
+ }
+});
+
+app.put('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => {
+ const parsed = validatePollInput(req.body);
+ if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; }
+ try {
+ await store.updatePoll(Number(req.params.pollId), parsed.type, parsed.question, parsed.options);
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `PUT /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err);
+ }
+});
+
+app.delete('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => {
+ try {
+ const ok = await store.deletePoll(Number(req.params.pollId));
+ if (!ok) { res.status(404).json({ error: 'Not found' }); return; }
+ res.json({ ok: true });
+ } catch (err: any) {
+ fail(res, `DELETE /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err);
+ }
+});
+
+// ── Ably token auth ──
+//
+// The heart of the security model. Each role gets a JWT whose capabilities are
+// scoped to exactly what that role needs on this one channel:
+// - voter: annotation-publish only (cast votes; can't read others' votes
+// or publish poll messages)
+// - presenter: annotation-subscribe + subscribe (read-only)
+// - admin: publish + subscribe + annotation-subscribe (drives the show)
+// The clientId baked into the token is what makes `vote:unique.v1` enforce one
+// vote per person.
+app.get('/auth', (req, res) => {
+ const clientId = req.query.clientId as string;
+ const sessionId = req.query.sessionId as string;
+ const role = req.query.role as string;
+
+ if (!clientId || !sessionId || !role) {
+ res.status(400).json({ error: 'Missing clientId, sessionId, or role' });
+ return;
+ }
+
+ if (role === 'admin') {
+ const password = req.query.password as string;
+ if (password !== ADMIN_PASSWORD) {
+ res.status(403).json('Invalid admin password');
+ return;
+ }
+ }
+
+ const channelName = `voting:${sessionId}`;
+ const capsByRole: Record = {
+ admin: ['publish', 'subscribe', 'annotation-subscribe'],
+ voter: ['subscribe', 'annotation-publish'],
+ presenter: ['subscribe', 'annotation-subscribe'],
+ };
+ const caps = capsByRole[role];
+ if (!caps) {
+ res.status(400).json({ error: 'Invalid role' });
+ return;
+ }
+ const capabilities: Record = { [channelName]: caps };
+
+ const now = Math.floor(Date.now() / 1000);
+ const token = jwt.sign(
+ {
+ 'x-ably-capability': JSON.stringify(capabilities),
+ 'x-ably-clientId': clientId,
+ iat: now,
+ exp: now + 3600,
+ },
+ keySecret,
+ {
+ header: {
+ typ: 'JWT',
+ alg: 'HS256',
+ kid: keyName,
+ },
+ },
+ );
+
+ res.set('Content-Type', 'application/jwt');
+ res.send(token);
+});
+
+// Optionally serve the built client (../../dist, i.e. the javascript/ project
+// root) from the same origin, so a production deploy needs only this one
+// server. In development you'd run the Vite dev server instead and let it proxy
+// the API here.
+const clientDist = path.resolve(__dirname, '../../dist');
+if (existsSync(clientDist)) {
+ app.use(express.static(clientDist));
+}
+
+const PORT = process.env.PORT || 3000;
+app.listen(PORT, () => {
+ console.log(`Server running on http://localhost:${PORT}`);
+});
diff --git a/examples/pub-sub-live-voting/javascript/server/src/showStore.ts b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts
new file mode 100644
index 0000000000..67c00ff7dc
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts
@@ -0,0 +1,54 @@
+// Data layer behind the admin API. Two implementations: a Postgres store
+// (db.ts) for full read/write, and a read-only static store (staticStore.ts)
+// backed by a JSON file for DB-less demo deployments. The server picks one at
+// startup based on the SHOWS_FILE env var.
+
+export type Slot = 'up' | 'right' | 'down' | 'left';
+
+export interface OptionInput {
+ label: string;
+ direction?: Slot | null;
+}
+
+export interface ShowSummary {
+ id: number;
+ name: string;
+ poll_count: number;
+}
+
+export interface StoredOption {
+ id: number;
+ label: string;
+ direction: string | null;
+ sort_order: number;
+}
+
+export interface StoredPoll {
+ id: number;
+ question: string;
+ type: string;
+ sort_order: number;
+ options: StoredOption[];
+}
+
+export interface ShowPolls {
+ show: { id: number; name: string };
+ polls: StoredPoll[];
+}
+
+export interface ShowStore {
+ /** When true, the write methods are unavailable and the admin UI is read-only. */
+ readonly readOnly: boolean;
+
+ getShows(): Promise;
+ getShowPolls(showId: number): Promise;
+
+ createShow(name: string): Promise<{ id: number; name: string }>;
+ updateShow(id: number, name: string): Promise;
+ deleteShow(id: number): Promise;
+
+ createPoll(showId: number, type: string, question: string, options: OptionInput[]): Promise;
+ updatePoll(pollId: number, type: string, question: string, options: OptionInput[]): Promise;
+ deletePoll(pollId: number): Promise;
+ reorderPolls(showId: number, pollIds: number[]): Promise;
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts
new file mode 100644
index 0000000000..c22196795e
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts
@@ -0,0 +1,92 @@
+import { readFileSync } from 'fs';
+import type {
+ ShowStore, ShowSummary, ShowPolls, StoredPoll, StoredOption,
+} from './showStore.js';
+
+// JSON authoring format (see ../../data/demo-shows.json):
+// { "shows": [ { "id", "name", "polls": [
+// { "id", "question", "type", "options": [ { "id", "label", "direction"? } ] }
+// ] } ] }
+interface RawOption { id: number; label: string; direction?: string | null; }
+interface RawPoll { id: number; question: string; type: string; options?: RawOption[]; }
+interface RawShow { id: number; name: string; polls?: RawPoll[]; }
+
+const VALID_TYPES = new Set(['list', 'dpad', 'suggest']);
+
+function readonlyWrite(): never {
+ throw new Error('Static show store is read-only; editing is disabled.');
+}
+
+function parseShows(filePath: string): RawShow[] {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
+ } catch (err: any) {
+ throw new Error(`Failed to read SHOWS_FILE "${filePath}": ${err.message}`);
+ }
+
+ const shows = (parsed as { shows?: unknown })?.shows;
+ if (!Array.isArray(shows)) {
+ throw new Error(`SHOWS_FILE "${filePath}" must contain a top-level "shows" array.`);
+ }
+
+ for (const show of shows as RawShow[]) {
+ if (typeof show.id !== 'number' || typeof show.name !== 'string') {
+ throw new Error('Each show needs a numeric "id" and a string "name".');
+ }
+ for (const poll of show.polls ?? []) {
+ if (typeof poll.id !== 'number' || typeof poll.question !== 'string') {
+ throw new Error(`Show ${show.id}: each poll needs a numeric "id" and a string "question".`);
+ }
+ if (!VALID_TYPES.has(poll.type)) {
+ throw new Error(`Show ${show.id}, poll ${poll.id}: invalid type "${poll.type}".`);
+ }
+ for (const opt of poll.options ?? []) {
+ if (typeof opt.id !== 'number' || typeof opt.label !== 'string') {
+ throw new Error(`Show ${show.id}, poll ${poll.id}: each option needs a numeric "id" and a string "label".`);
+ }
+ }
+ }
+ }
+
+ return shows as RawShow[];
+}
+
+export function createStaticStore(filePath: string): ShowStore {
+ const shows = parseShows(filePath);
+ const byId = new Map(shows.map((s) => [s.id, s]));
+
+ return {
+ readOnly: true,
+
+ async getShows(): Promise {
+ return shows.map((s) => ({ id: s.id, name: s.name, poll_count: (s.polls ?? []).length }));
+ },
+
+ async getShowPolls(showId: number): Promise {
+ const show = byId.get(showId);
+ if (!show) return null;
+ const polls: StoredPoll[] = (show.polls ?? []).map((p, pi) => ({
+ id: p.id,
+ question: p.question,
+ type: p.type,
+ sort_order: pi,
+ options: (p.options ?? []).map((o, oi): StoredOption => ({
+ id: o.id,
+ label: o.label,
+ direction: o.direction ?? null,
+ sort_order: oi,
+ })),
+ }));
+ return { show: { id: show.id, name: show.name }, polls };
+ },
+
+ createShow: readonlyWrite,
+ updateShow: readonlyWrite,
+ deleteShow: readonlyWrite,
+ createPoll: readonlyWrite,
+ updatePoll: readonlyWrite,
+ deletePoll: readonlyWrite,
+ reorderPolls: readonlyWrite,
+ };
+}
diff --git a/examples/pub-sub-live-voting/javascript/server/tsconfig.json b/examples/pub-sub-live-voting/javascript/server/tsconfig.json
new file mode 100644
index 0000000000..2cb7b6f25c
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/server/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "lib": ["ES2022"],
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/admin.ts b/examples/pub-sub-live-voting/javascript/src/admin.ts
new file mode 100644
index 0000000000..59965423cc
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/admin.ts
@@ -0,0 +1,986 @@
+import * as Ably from 'ably';
+import { createAblyClient, getChannel } from './shared/ably';
+import { renderChart } from './shared/chart';
+import { displayName } from './shared/clientId';
+import { showError } from './shared/errors';
+import { generateSessionId } from './shared/sessionId';
+import { IS_SANDBOX } from './config';
+import {
+ VOTE_ANNOTATION_TYPE,
+ SUGGESTION_ANNOTATION_TYPE,
+ SESSION_KEY,
+ AdminState,
+ PollMessage,
+ PollType,
+ ControlMessage,
+ PollOption,
+ PollDefinition,
+ ControllerKind,
+ SLOTS_BY_KIND,
+ isControllerKind,
+} from './shared/types';
+import { slotGlyph } from './shared/controller';
+
+// The operator console. It drives the show — it's the only role that *publishes
+// poll messages*. Voters and the presenter only react to them. This view talks
+// to the backend (the shows API + token auth), so it's not part of the
+// in-browser sandbox demo; clone the repo and run the server to use it.
+
+const VIEW_HTML = `
+
`;
+ }
+ initTabs();
+ initManage();
+ initShowRunner();
+
+ const saved = loadSavedShowState();
+ if (saved) {
+ activateShowTab();
+ try {
+ await connectToShow(saved.showId, {
+ sessionId: saved.sessionId,
+ initialState: saved.state,
+ pollIndex: saved.currentPollIndex,
+ });
+ } catch (err: any) {
+ showError(`Failed to resume show: ${err.message || err}`);
+ clearSavedShowState();
+ }
+ }
+}
+
+function activateShowTab() {
+ const tabManage = $('#tab-manage');
+ const tabShow = $('#tab-show');
+ tabShow.classList.add('active');
+ tabShow.setAttribute('aria-selected', 'true');
+ tabManage.classList.remove('active');
+ tabManage.setAttribute('aria-selected', 'false');
+}
+
+export function mount() {
+ document.body.dataset.view = 'admin';
+ document.body.dataset.state = 'manage';
+
+ // The admin console needs the backend (shows API + token auth), which the
+ // in-browser sandbox doesn't have. Clone the repo and run the server to use
+ // it; the live docs demo only exercises the voter and presenter.
+ if (IS_SANDBOX) {
+ document.body.innerHTML = `
+
+
+
Admin runs against the backend
+
The operator console drives the show and talks to the shows API and token-auth
+ endpoint, so it isn't part of the in-browser demo. Clone the repo and run the server
+ (see the README) to use it.
+
+
`;
+ return;
+ }
+
+ document.body.innerHTML = VIEW_HTML;
+ init();
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/config.ts b/examples/pub-sub-live-voting/javascript/src/config.ts
new file mode 100644
index 0000000000..a8d05a14be
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/config.ts
@@ -0,0 +1,22 @@
+// Configuration and sandbox detection.
+//
+// In the hosted docs sandbox the build replaces `import.meta.env.VITE_ABLY_KEY`
+// and `import.meta.env.VITE_NAME` with real string literals, so the demo runs
+// entirely in the browser with no backend. In a normal clone these are
+// undefined, and the app talks to the bundled server instead (see ../../server).
+//
+// Only reference env vars that the docs build injects (VITE_ABLY_KEY,
+// VITE_NAME). Other `import.meta.env` lookups would survive into the sandbox
+// bundle and break it — the server-backed config (auth URL, API base) is wired
+// through same-origin paths and a Vite dev proxy instead (see vite.config.ts).
+
+export const SANDBOX_ABLY_KEY = import.meta.env.VITE_ABLY_KEY as string | undefined;
+
+// A shared channel seed. In the sandbox both preview panes get the same
+// injected value, so the voter and presenter land on the same channel without
+// exchanging a session id. In a clone this is undefined and the session id
+// comes from the URL (`?s=...`) instead.
+export const SANDBOX_CHANNEL_SEED = import.meta.env.VITE_NAME as string | undefined;
+
+/** True only inside the hosted docs sandbox (a raw key was injected). */
+export const IS_SANDBOX = Boolean(SANDBOX_ABLY_KEY);
diff --git a/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts
new file mode 100644
index 0000000000..7ee4bab9df
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts
@@ -0,0 +1,102 @@
+import * as Ably from 'ably';
+import { SANDBOX_ABLY_KEY, SANDBOX_CHANNEL_SEED } from './config';
+import { votingChannelName } from './shared/ably';
+import demoShows from './demo-shows.json';
+import { PollMessage, PollOption, VOTE_ANNOTATION_TYPE } from './shared/types';
+
+// ─── SANDBOX ONLY ───────────────────────────────────────────────────────────
+// None of this is part of the real app. In a real show the admin console
+// starts the show and a live audience supplies the votes. The hosted docs
+// demo has neither, so this module fakes a "host": it publishes one poll and
+// seeds a handful of votes from throwaway clients, purely so the presenter
+// pane is alive and expressive the moment the page loads. The real voter pane
+// then adds its own live votes on top.
+//
+// Clone the repo and run the server (see the README) for the genuine flow —
+// you'll never use this file there.
+
+// Throwaway clients that each cast one vote. Distinct clientIds matter: votes
+// are `unique` annotations, deduped per clientId, so N distinct voters are what
+// give the heatmap N counts to spread across the options.
+const SEED_VOTERS = 6;
+
+const channelName = () => votingChannelName(SANDBOX_CHANNEL_SEED!);
+
+// Auto-start the d-pad poll — the most expressive presenter screen (a heatmap
+// with a leader badge), as opposed to a plain bar chart or the suggest stage.
+function pickDpadPoll(): PollMessage | null {
+ const show = (demoShows as { shows?: any[] }).shows?.[0];
+ const polls: any[] = show?.polls ?? [];
+ const dpad = polls.find((p) => p.type === 'dpad') ?? polls[0];
+ if (!dpad) return null;
+ return {
+ pollId: dpad.id,
+ question: dpad.question,
+ type: dpad.type,
+ // Option ids are strings on the wire (the server stringifies them); match that.
+ options: (dpad.options ?? []).map((o: any): PollOption => ({
+ id: String(o.id),
+ label: o.label,
+ direction: o.direction,
+ })),
+ };
+}
+
+export async function runDemoHost() {
+ const poll = pickDpadPoll();
+ if (!poll) return;
+
+ const host = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: 'demo-host' });
+ const channel = host.channels.get(channelName(), { modes: ['publish', 'subscribe'] });
+
+ // Publishing returns void, so read the serial back from the echoed message —
+ // the seed votes are annotations and need the poll's serial to attach to.
+ channel.subscribe('poll', (msg: Ably.Message) => {
+ const data = msg.data as PollMessage;
+ if (msg.serial && data.pollId === poll.pollId) {
+ channel.unsubscribe('poll');
+ seedVotes(poll, msg.serial);
+ }
+ });
+
+ try {
+ await channel.publish('poll', poll);
+ } catch {
+ /* best-effort demo seeding; ignore failures */
+ }
+}
+
+function seedVotes(poll: PollMessage, serial: string) {
+ // Stagger them so the presenter shows a trickle of vote bubbles rather than
+ // one synchronised burst.
+ for (let i = 0; i < SEED_VOTERS; i++) {
+ setTimeout(() => castSeedVote(poll, serial), 600 + i * 350);
+ }
+}
+
+function weightedPick(options: PollOption[]): PollOption {
+ // Bias toward earlier options so a clear leader emerges on the heatmap.
+ const weights = options.map((_, i) => options.length - i);
+ const total = weights.reduce((a, b) => a + b, 0);
+ let r = Math.random() * total;
+ for (let i = 0; i < options.length; i++) {
+ r -= weights[i];
+ if (r <= 0) return options[i];
+ }
+ return options[0];
+}
+
+function castSeedVote(poll: PollMessage, serial: string) {
+ if (poll.options.length === 0) return;
+ const opt = weightedPick(poll.options);
+ const suffix = Math.random().toString(36).slice(2, 6);
+ // `Guest-xxxx` so the presenter's displayName() shows "Guest" on the bubble.
+ const bot = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: `Guest-${suffix}` });
+ const channel = bot.channels.get(channelName(), { modes: ['annotation_publish'] });
+ channel.annotations
+ .publish(serial, { type: VOTE_ANNOTATION_TYPE, name: opt.id })
+ .catch(() => {})
+ // The vote persists in the summary after the bot leaves (annotations aren't
+ // presence), so we can disconnect to free the connection.
+ .finally(() => setTimeout(() => bot.close(), 2000));
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/demo-shows.json b/examples/pub-sub-live-voting/javascript/src/demo-shows.json
new file mode 100644
index 0000000000..ce877c4447
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/demo-shows.json
@@ -0,0 +1,38 @@
+{
+ "shows": [
+ {
+ "id": 1,
+ "name": "Ably Live Voting demo",
+ "polls": [
+ {
+ "id": 1,
+ "question": "Which real-time feature do you reach for most?",
+ "type": "list",
+ "options": [
+ { "id": 101, "label": "Pub/Sub channels" },
+ { "id": 102, "label": "Presence" },
+ { "id": 103, "label": "Message history" },
+ { "id": 104, "label": "Push notifications" }
+ ]
+ },
+ {
+ "id": 2,
+ "question": "Rate this demo with the d-pad",
+ "type": "dpad",
+ "options": [
+ { "id": 201, "label": "Love it", "direction": "up" },
+ { "id": 202, "label": "It's neat", "direction": "right" },
+ { "id": 203, "label": "Meh", "direction": "down" },
+ { "id": 204, "label": "Confused", "direction": "left" }
+ ]
+ },
+ {
+ "id": 3,
+ "question": "What should we build with Ably next?",
+ "type": "suggest",
+ "options": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/presenter.ts b/examples/pub-sub-live-voting/javascript/src/presenter.ts
new file mode 100644
index 0000000000..37b80ca728
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/presenter.ts
@@ -0,0 +1,316 @@
+import * as Ably from 'ably';
+import QRCode from 'qrcode';
+import { createAblyClient, getChannel } from './shared/ably';
+import { renderChart } from './shared/chart';
+import { renderController } from './shared/controller';
+import { displayName } from './shared/clientId';
+import { isValidSessionId } from './shared/sessionId';
+import { IS_SANDBOX, SANDBOX_CHANNEL_SEED } from './config';
+import {
+ VOTE_ANNOTATION_TYPE,
+ SUGGESTION_ANNOTATION_TYPE,
+ SESSION_KEY,
+ PresenterState,
+ PollMessage,
+ PollType,
+ ControlMessage,
+ PollOption,
+ isControllerKind,
+} from './shared/types';
+
+// The big-screen view. The presenter is the consumer that wants *individual*
+// annotation events, not just the summary: every vote and every suggestion
+// becomes a floating bubble. That's a much higher-volume stream than the
+// summary the voters get — which is exactly why annotations let each audience
+// subscribe to the slice it needs.
+
+const VIEW_HTML = `
+
Ably Live Voting
+
+
+
+
+
That session ID doesn't look right
+
Please double-check the URL.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scan to join the show →
+
+
+
+
+
+
+
That's all, folks!
+
Thanks for taking part.
+
+
+
+
+
+
+`;
+
+let channel: Ably.RealtimeChannel | null = null;
+let currentPollOptions: PollOption[] = [];
+let currentPollType: PollType = 'list';
+let currentPollSerial: string | null = null;
+let currentPollId: number | null = null;
+let currentSummary: Ably.SummaryUniqueValues | null = null;
+let suggestListener: ((a: Ably.Annotation) => void) | null = null;
+let voteListener: ((a: Ably.Annotation) => void) | null = null;
+let acceptingSuggestions = false;
+
+const $ = (sel: string) => document.querySelector(sel) as HTMLElement;
+
+function setState(newState: PresenterState) {
+ document.body.dataset.state = newState;
+}
+
+function handleMessage(msg: Ably.Message) {
+ if (msg.name === 'poll') {
+ const data = msg.data as PollMessage;
+ const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined;
+
+ if (msg.serial !== currentPollSerial) {
+ currentPollOptions = data.options;
+ currentPollType = data.type ?? 'list';
+ currentPollSerial = msg.serial!;
+ currentPollId = data.pollId;
+ currentSummary = summary ?? null;
+
+ if (currentPollType === 'suggest') {
+ unsubscribeSuggestions();
+ unsubscribeVotes();
+ $('#suggest-question').textContent = data.question;
+ $('#bubble-layer').innerHTML = '';
+ subscribeSuggestions();
+ setState('suggesting');
+ } else {
+ unsubscribeSuggestions();
+ $('#question').textContent = data.question;
+ renderResults();
+ setState('live');
+ subscribeVotes();
+ }
+ } else if (summary && currentPollType !== 'suggest') {
+ currentSummary = summary;
+ renderResults();
+ }
+ } else if (msg.name === 'control') {
+ const data = msg.data as ControlMessage;
+ if (data.action === 'end') {
+ unsubscribeSuggestions();
+ unsubscribeVotes();
+ setState('ended');
+ return;
+ }
+ if (data.action === 'reset') {
+ unsubscribeSuggestions();
+ unsubscribeVotes();
+ currentPollSerial = null;
+ currentPollId = null;
+ currentPollOptions = [];
+ currentSummary = null;
+ if (data.voterUrl) showQR(data.voterUrl);
+ return;
+ }
+ if (data.action === 'clear-suggestions') {
+ $('#bubble-layer').innerHTML = '';
+ return;
+ }
+ if (data.pollId === currentPollId) {
+ if (data.action === 'close') {
+ if (currentPollType === 'suggest') {
+ acceptingSuggestions = false;
+ } else {
+ setState('results');
+ }
+ } else if (data.action === 'show-qr') {
+ showQR(data.voterUrl!);
+ }
+ }
+ }
+}
+
+async function showQR(voterUrl: string) {
+ const canvas = $('#qr-canvas') as HTMLCanvasElement;
+ await QRCode.toCanvas(canvas, voterUrl, { width: 1000, margin: 2 });
+ // qrcode pins an inline 1000px width/height on the canvas, which would
+ // override our stylesheet sizing. Clear it so the CSS clamp() wins while
+ // the 1000px bitmap stays for crisp rendering.
+ canvas.style.width = '';
+ canvas.style.height = '';
+ setState('show-qr');
+}
+
+function renderResults() {
+ const chart = $('#chart');
+ if (isControllerKind(currentPollType)) {
+ // The d-pad heatmap: each slot fills proportionally to its share of the
+ // leader, with a star badge on the winner — all driven by the summary.
+ chart.classList.add('presenter-controller');
+ renderController(currentPollType, chart, currentPollOptions, currentSummary, {
+ showHeatmap: true,
+ showLeaderBadge: true,
+ display: 'percent',
+ });
+ } else {
+ chart.classList.remove('presenter-controller', 'controller', 'controller-dpad');
+ renderChart(chart, currentPollOptions, currentSummary, 'percent');
+ }
+}
+
+function subscribeSuggestions() {
+ if (!channel || suggestListener) return;
+ acceptingSuggestions = true;
+ const handler = (annotation: Ably.Annotation) => {
+ if (!acceptingSuggestions) return;
+ if (annotation.action === 'annotation.delete') return;
+ const text = annotation.name;
+ const cid = annotation.clientId;
+ if (!text || !cid) return;
+ spawnBubble($('#bubble-layer'), text, displayName(cid), { baseMs: 16000, decayPerAlive: 300, minMs: 3000 });
+ };
+ suggestListener = handler;
+ channel.annotations.subscribe(SUGGESTION_ANNOTATION_TYPE, handler);
+}
+
+function unsubscribeSuggestions() {
+ acceptingSuggestions = false;
+ if (channel && suggestListener) {
+ channel.annotations.unsubscribe(SUGGESTION_ANNOTATION_TYPE, suggestListener);
+ }
+ suggestListener = null;
+}
+
+function subscribeVotes() {
+ if (!channel || voteListener) return;
+ // One bubble per individual vote annotation. This is the high-volume stream
+ // the summary spares the voters from; the presenter opts in via
+ // annotation_subscribe.
+ const handler = (annotation: Ably.Annotation) => {
+ if (annotation.action === 'annotation.delete') return;
+ const optionId = annotation.name;
+ const cid = annotation.clientId;
+ if (!optionId || !cid) return;
+ const opt = currentPollOptions.find((o) => o.id === optionId);
+ if (!opt) return;
+ spawnBubble($('#vote-bubble-layer'), opt.label, displayName(cid), { baseMs: 1500, decayPerAlive: 0, minMs: 1500 });
+ };
+ voteListener = handler;
+ channel.annotations.subscribe(VOTE_ANNOTATION_TYPE, handler);
+}
+
+function unsubscribeVotes() {
+ if (channel && voteListener) {
+ channel.annotations.unsubscribe(VOTE_ANNOTATION_TYPE, voteListener);
+ }
+ voteListener = null;
+}
+
+interface BubbleLifetime {
+ baseMs: number;
+ decayPerAlive: number;
+ minMs: number;
+}
+
+function spawnBubble(layer: HTMLElement, text: string, attribution: string, lifetime: BubbleLifetime) {
+ const el = document.createElement('div');
+ el.className = 'bubble';
+ el.innerHTML = ``;
+ el.querySelector('.bubble-text')!.textContent = text;
+ el.querySelector('.bubble-attribution')!.textContent = attribution;
+
+ const { x, y } = pickBubblePosition();
+ el.style.left = `${x}%`;
+ el.style.top = `${y}%`;
+
+ const alive = layer.children.length;
+ const lifetimeMs = Math.max(lifetime.minMs, lifetime.baseMs - alive * lifetime.decayPerAlive);
+ el.style.setProperty('--lifetime', `${lifetimeMs}ms`);
+ el.addEventListener('animationend', () => el.remove(), { once: true });
+
+ layer.appendChild(el);
+}
+
+function pickBubblePosition(): { x: number; y: number } {
+ // Polar around the centre, avoiding the question text in the middle.
+ const angle = Math.random() * Math.PI * 2;
+ const r = 25 + Math.random() * 20; // 25–45% of half-viewport
+ const x = 50 + Math.cos(angle) * r;
+ const y = 50 + Math.sin(angle) * r;
+ return { x, y };
+}
+
+function connect(sessionId: string) {
+ const clientId = `presenter-${crypto.randomUUID().slice(0, 8)}`;
+ const ably = createAblyClient(clientId, sessionId, 'presenter');
+ channel = getChannel(ably, sessionId, 'presenter');
+ channel.subscribe(handleMessage);
+}
+
+function init() {
+ // Sandbox: join the shared demo channel and wait for the auto-started poll
+ // (published by demo-bootstrap). No QR — the voter pane is already connected.
+ if (IS_SANDBOX) {
+ document.body.dataset.sandbox = 'true';
+ $('#show-qr-heading').textContent = 'Connecting…';
+ setState('show-qr');
+ connect(SANDBOX_CHANNEL_SEED!);
+ return;
+ }
+
+ const sessionId = new URLSearchParams(window.location.search).get(SESSION_KEY);
+ if (!sessionId || !isValidSessionId(sessionId)) {
+ setState('invalid-session');
+ return;
+ }
+
+ connect(sessionId);
+
+ // Carry the presenter's querystring (the session) straight into the voter
+ // URL — admin builds the presenter link with the same querystring, so they
+ // stay in sync.
+ const voterUrl = `${window.location.origin}/${window.location.search}`;
+ showQR(voterUrl);
+
+ // Manual-entry fallback: the short URL displayed under the QR for people
+ // without a working camera. It already carries just the session, so it
+ // matches the QR target.
+ const qrLink = $('#presenter-qr-url') as HTMLAnchorElement;
+ qrLink.textContent = `${window.location.host}/?${SESSION_KEY}=${sessionId}`;
+ qrLink.href = voterUrl;
+}
+
+export function mount() {
+ document.body.dataset.view = 'presenter';
+ document.body.dataset.state = 'show-qr';
+ document.body.innerHTML = VIEW_HTML;
+ init();
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/script.ts b/examples/pub-sub-live-voting/javascript/src/script.ts
new file mode 100644
index 0000000000..ff71259394
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/script.ts
@@ -0,0 +1,25 @@
+// Single entry point. The app has three roles — voter, presenter, admin —
+// selected by the `?role=` query param. Splitting one app this way (rather
+// than three HTML pages) lets the docs sandbox show two of the roles side by
+// side in separate preview panes, each loaded with a different `?role=`.
+import { mount as mountVoter } from './voter';
+import { mount as mountPresenter } from './presenter';
+import { mount as mountAdmin } from './admin';
+import { runDemoHost } from './demo-bootstrap';
+import { IS_SANDBOX } from './config';
+
+const params = new URLSearchParams(window.location.search);
+const role = params.get('role') ?? 'voter';
+
+if (role === 'presenter') {
+ mountPresenter();
+ // Sandbox only: `?demo=1` also makes this pane the host that auto-starts the
+ // show, since there's no admin running. See demo-bootstrap.ts.
+ if (IS_SANDBOX && params.get('demo') === '1') {
+ runDemoHost();
+ }
+} else if (role === 'admin') {
+ mountAdmin();
+} else {
+ mountVoter();
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/shared/ably.ts b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts
new file mode 100644
index 0000000000..9025e550f4
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts
@@ -0,0 +1,86 @@
+import * as Ably from 'ably';
+import { Role } from './types';
+import { showError, clearError } from './errors';
+import { IS_SANDBOX, SANDBOX_ABLY_KEY } from '../config';
+
+export function createAblyClient(
+ clientId: string,
+ sessionId: string,
+ role: Role,
+ extraParams?: Record,
+): Ably.Realtime {
+ // ── Authentication ──
+ //
+ // Real app: the client never sees an API key. It calls the server's `/auth`
+ // endpoint, which returns a short-lived JWT scoped to exactly this role's
+ // capabilities — a voter token can publish annotations but cannot publish
+ // messages or read other voters' raw annotations (see server/src/server.ts).
+ // This is the pattern you should use in production.
+ //
+ // Hosted docs sandbox ONLY: there is no server to call, so we fall back to a
+ // raw API key embedded in the page. Do not do this with a real app, it's used here
+ // purely so the live demo can run without a backend.
+ const client = new Ably.Realtime(
+ IS_SANDBOX
+ ? { key: SANDBOX_ABLY_KEY, clientId }
+ : {
+ authUrl: '/auth',
+ authParams: { clientId, sessionId, role, ...extraParams },
+ clientId,
+ },
+ );
+
+ client.connection.on('failed', (change) => {
+ const reason = change.reason;
+ const detail = reason?.cause?.message || reason?.message || 'unknown error';
+ showError(`Connection failed: ${detail}`);
+ });
+
+ client.connection.on('connected', () => {
+ clearError();
+ });
+
+ return client;
+}
+
+// Channel modes are the client's declared intent, enforced by Ably. They're
+// the second layer of least-privilege after the token capabilities: a voter
+// attaches with annotation_publish only, so it can cast votes but never
+// receive the (much higher-volume) individual annotation stream the presenter
+// consumes, nor publish poll messages.
+const MODES_BY_ROLE: Record = {
+ // Admin publishes poll/control messages and subscribes for echo + summaries.
+ // Also subscribes to per-annotation events to render the live suggest list.
+ admin: ['publish', 'subscribe', 'annotation_subscribe'],
+ // Voter only publishes annotations (votes, suggestions) and subscribes for
+ // poll/control messages.
+ voter: ['subscribe', 'annotation_publish'],
+ // Presenter is read-only on both messages and per-annotation events.
+ presenter: ['subscribe', 'annotation_subscribe'],
+};
+
+// The channel a session runs on. Always namespaced under `voting:` so the
+// channel rules this app needs, such as Message annotations and serverside batching, can
+// be configured against the `voting` namespace. In the sandbox `sessionId` is the
+// injected channel name; in the real app it's the admin-generated session id.
+export function votingChannelName(sessionId: string): string {
+ return `voting:${sessionId}`;
+}
+
+export function getChannel(client: Ably.Realtime, sessionId: string, role: Role): Ably.RealtimeChannel {
+ // `rewind: 1` means a client that attaches mid-poll immediately receives the
+ // current poll message (and its latest annotation summary), so late joiners
+ // and page refreshes catch up without the admin re-publishing.
+ const channel = client.channels.get(votingChannelName(sessionId), {
+ params: { rewind: '1' },
+ modes: MODES_BY_ROLE[role],
+ });
+
+ channel.on('failed', (change) => {
+ const reason = change.reason;
+ const detail = reason?.cause?.message || reason?.message || 'unknown error';
+ showError(`Channel error: ${detail}`);
+ });
+
+ return channel;
+}
diff --git a/examples/pub-sub-live-voting/javascript/src/shared/chart.ts b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts
new file mode 100644
index 0000000000..e26e31235d
--- /dev/null
+++ b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts
@@ -0,0 +1,60 @@
+import * as Ably from 'ably';
+import { PollOption } from './types';
+import { CountDisplay, formatCount } from './format';
+
+const COLORS = ['#ff5416', '#1a1a1a', '#6b6b6b', '#ffa07a', '#c9c5bd', '#ff7b47'];
+
+// Renders a horizontal bar per option from an annotation summary. The summary
+// is Ably's server-side rollup of every `vote:unique.v1` annotation on the
+// poll message — `summary[optionId].total` is the live vote count for that
+// option, computed for us, not by tallying events on the client.
+export function renderChart(
+ container: HTMLElement,
+ options: PollOption[],
+ summary: Ably.SummaryUniqueValues | null,
+ display: CountDisplay = 'count',
+): void {
+ const totalVotes = options.reduce(
+ (sum, opt) => sum + (summary?.[opt.id]?.total ?? 0),
+ 0,
+ );
+
+ // Reuse existing rows if structure matches, otherwise rebuild
+ const existingRows = container.querySelectorAll('.chart-row');
+ if (existingRows.length !== options.length) {
+ container.innerHTML = '';
+ for (let i = 0; i < options.length; i++) {
+ const opt = options[i];
+ const count = summary?.[opt.id]?.total ?? 0;
+ const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
+
+ const row = document.createElement('div');
+ row.className = 'chart-row';
+ row.dataset.optionId = opt.id;
+ row.innerHTML = `
+ ${opt.label}
+