Skip to content

Build a realtime whiteboard

This guide builds the whiteboard that ships as Consortium’s reference extension, from an empty directory, using nothing that isn’t public: the @consortium/plugin-sdk package, Yjs, and a bundler. Everything hard about collaboration — encryption, membership, ordering, offline catch-up, revocation — is the host’s job. Your app hands over opaque bytes and receives opaque bytes.

The finished source lives at packages/consortium-plugin-sdk/examples/whiteboard in the Consortium repository; board.js next to it is the annotated teaching copy of the same integration. Diff yourself against it whenever something here is unclear.

Read Extensions first for the security model. This guide is the practice to that theory.

Three files make an extension: a manifest, an HTML shell, and a bundled script. Start with consortium.json:

{
"manifestVersion": 1,
"id": "com.example.whiteboard",
"name": "Whiteboard",
"version": "1.0.0",
"surface": "view",
"entry": "index.html",
"capabilities": {
"doc": { "mode": "readwrite", "schema": "example.whiteboard.v1", "maxBytes": 52428800 },
"presence": true,
"export": ["png"],
"net": []
},
"placement": ["workspace", "org"]
}

Every capability is a consent prompt the user will see, so declare the set you can live with — widening it later re-prompts every installer. net: [] is not boilerplate: an empty network capability is what lets a user trust that their drawings cannot leave.

The SDK validates this shape for you:

import { validateManifest } from '@consortium/plugin-sdk';
const problems = validateManifest(JSON.parse(manifestText));
// [] means publishable; anything else names the field and the fix

2. Connect, and branch on what you were GRANTED

Section titled “2. Connect, and branch on what you were GRANTED”
import * as Y from 'yjs';
import { connect, toBase64, fromBase64 } from '@consortium/plugin-sdk';
const host = await connect();
const readOnly = host.caps.doc !== 'readwrite';

connect() resolves only once the host has granted capabilities, and host.caps may be narrower than the manifest asked for — a user can decline presence, an org policy can clamp a readwrite doc to read. Branch on host.caps, never on your manifest. The reference app hides its peers indicator when presence wasn’t granted and shows a read-only banner instead of a palette when the doc is clamped.

Your state is a Yjs document. The host carries updates both ways as opaque base64; you merge and emit.

const doc = new Y.Doc();
const strokes = doc.getArray('strokes');
// 'remote' as the transaction origin is load-bearing: it is how the emit
// listener below tells "the host gave me this" apart from "the user drew
// this". Without it, every inbound update is echoed straight back out — a
// sync loop that also doubles your permanent operation count.
const applyRemote = ({ update }) => Y.applyUpdate(doc, fromBase64(update), 'remote');
host.on('doc.state', applyRemote); // the board as it stood when you mounted
host.on('doc.remote', applyRemote); // somebody else drawing, live
doc.on('update', (update, origin) => {
if (origin === 'remote' || readOnly) return;
host.docUpdate(toBase64(update)).catch(reportProblem);
});
strokes.observeDeep(render);

That is the entire sync integration. Two people with this code open see each other’s strokes as they land; someone who was offline converges on reopen — the host replays what they missed by sequence number, and CRDT merge does the rest.

4. One operation per stroke — the rule that keeps documents small

Section titled “4. One operation per stroke — the rule that keeps documents small”

Every update you emit becomes a permanent entry in an append-only, per-op sealed log. Operation count is the cost that lasts. A fast pointer emits hundreds of samples per stroke; committing per sample writes hundreds of permanent ops where one would do, and the difference cannot be compacted away later.

Accumulate locally while the pointer is down, commit once on release:

let drawing = null;
surface.addEventListener('pointerdown', (e) => {
if (readOnly) return;
drawing = { color, points: [pointOf(e)] };
});
surface.addEventListener('pointermove', (e) => {
if (!drawing) return;
const p = pointOf(e);
// Also drop samples the renderer can't distinguish (< ~1.5px apart).
drawing.points.push(p);
renderOverlay(); // draw the in-progress stroke locally, outside the doc
});
surface.addEventListener('pointerup', () => {
if (drawing && drawing.points.length > 1) {
// A stable id, so undo can find this stroke again after other
// people's concurrent edits have shifted every index around it.
strokes.push([{ ...drawing, id: crypto.randomUUID() }]);
}
drawing = null;
});

The same reasoning gives you correct multi-user undo for free: remember the ids of strokes you pushed, and delete by id, never by index —

const index = strokes.toArray().findIndex((s) => s.id === myLastId);
if (index >= 0) strokes.delete(index, 1);

— because an index you remembered is stale the moment a collaborator draws.

Presence is the half of realtime that must never touch the document log: it is continuous, instantly stale, and worthless a moment later. It rides an ephemeral channel that is relayed live and never stored, so it is exempt from the one-op rule — send it as often as you like.

surface.addEventListener('pointermove', (e) => {
if (host.caps.presence && !readOnly) {
host.presenceSet(toBase64(encodePoint(pointOf(e)))).catch(() => {});
}
});
const cursors = new Map(); // ref -> { label, at, seenAt }
host.on('presence.list', ({ peers }) => {
for (const p of peers) {
if (!p.cursor) continue;
try {
cursors.set(p.ref, { label: p.label, at: decodePoint(fromBase64(p.cursor)), seenAt: Date.now() });
} catch {
// A peer on a newer build may encode differently. Skip the one
// unreadable cursor; don't drop every other peer with it.
}
}
renderOverlay();
});

Two things are deliberate in that shape:

  • ref is opaque. It is a per-session handle, not an account id — your sandbox never learns who anyone is, only that somebody moved.
  • label comes from the host. The person’s display name is chosen by the host that knows them; your extension cannot set its own, which is what stops one collaborator impersonating another.

Expire cursors you haven’t heard from (the reference uses a 30-second TTL) so a closed tab stops hovering over everyone’s board. Encode the cursor payload compactly — the reference packs x,y into four bytes — because it fans out to everyone on every move.

Your sandbox has no filesystem and never learns a path. Hand the bytes over and the host owns the save dialog:

const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
const bytes = new Uint8Array(await blob.arrayBuffer());
await host.exportFile('png', toBase64(bytes), 'board.png');

Handle three things and you are honest with your users:

function reportProblem(err) {
if (err?.code === 'forbidden') return showBanner('This board is read-only for you.');
if (err?.code === 'throttled') return; // transient; the next edit will land
showBanner('Could not save your last change.');
}
host.on('integrity', ({ message }) => showBanner(message));

doc.remote can arrive at any time, including mid-stroke — the code above already survives that, because remote updates go into the Yjs doc and your in-progress stroke lives outside it until commit.

The bundle must be self-contained: no bare imports, no CDN. Any bundler works; the reference uses esbuild:

esbuild src/main.js --bundle --format=iife --target=es2020 --outfile=dist/board.js
cp src/index.html dist/index.html

index.html is plain markup — a canvas, an overlay canvas, a palette — with no inline script; the host injects your bundle into a sandboxed, opaque-origin frame after verifying its digest.

9. Publish and run (development / self-hosted)

Section titled “9. Publish and run (development / self-hosted)”

On a server you operate, publishing is one recipe — it packs the bundle, uploads it, and records the digest that every device will verify before executing a byte:

cd packages/consortium-server
yarn tsx sources/recipes/publishViewBundle.ts \
--dir path/to/your-extension --slug your-whiteboard --entry board.js

Then, in the app: install it from the marketplace, open a shared workspace, and add your app as a tab. Open the same tab from a second account in that workspace. You should see: the same board (the tab IS the document — same tab, same board, for everyone who can see it), strokes landing live in both directions, and each other’s named cursors moving.

Hosted third-party publishing — identity, signatures, review — is the part of the platform still to come; see Extensions § Publishing.

Worth pausing on, because it is the pitch: this app contains no encryption, no transport, no membership, no reconnect logic, and no server code. Sharing the board is sharing the workspace; revoking access is removing someone from it; a member who loses access loses the ability to decrypt anything sealed after their eviction. All of that is the platform’s, enforced identically for every extension — which is exactly why it can be trusted with none of it.