Module SDK

The developer reference for custom HTML5 modules. Everything your module page needs is on one object — window.EventSync — injected automatically into every module view.

Overview

A custom module is plain HTML/CSS/JavaScript. You don't include any script tag or library — the server injects the EventSync Module SDK into every stored module page, so window.EventSync is simply there when your code runs. The same page works unchanged on iPads, Android devices and in Dashboard module windows.

The SDK gives you these things:

💡 Older apps and servers

The SDK degrades gracefully: on a host that hasn't been updated yet, everything above still works — actions are still delivered and state still arrives — you just get weaker guarantees (see the legacy flag under dispatch). Write against this API and you're covered both ways.

The three views

Custom modules use the same three-view structure as the built-ins (see Module views under Curate):

ViewRuns onTypical job
IndexRequiredGuest devices (iPad / Android), fullscreenThe interactive guest surface — vote, answer, bid.
ControlOptionalDashboard module window or a backstage deviceOperator settings and moderation — writes module state.
ResultsOptionalBig screens / LED wallsAggregated output for the room.

All three views run the same SDK. EventSync.context.view tells you which one you are, and module state is shared between all of them, so a change made on Control appears on every Index and Results view immediately.

EventSync.context

EventSync.context is a plain object describing where your page is running. Read it whenever you need it — the device fields are filled in by the host and may start as null for the first moments of a page load.

FieldTypeMeaning
moduleIdstringThis module's unique id.
viewstring"index", "control" or "results".
eventIdstring / nullThe active event's id, or null when no event is active.
deviceIdstring / nullThe identity of the device showing this view. On iPads and Android devices connected to a current server this is stamped by the server — it is the authoritative device record, the same id you see in Clients and in stored responses, and a page cannot fake it. In Dashboard module windows there is no device, so it is null.
deviceNamestring / nullThe device's display name. null in Dashboard windows.
groupNamestring / nullThe group the device belongs to. null until the device is grouped.
contractVersionnumberThe SDK contract version this host speaks. Currently 1.
capabilitiesstring[]The capability names this host supports — the same list as EventSync.capabilities.list().

dispatch(action, payload)

Everything a module sends to the server is an action — a name plus a payload object. dispatch returns a Promise that settles with an acknowledgement:

const ack = await EventSync.dispatch('vote', { choice: 'yes' });

Ack semantics

Always attach a .catch() — a rejected dispatch is your cue to re-enable the button and let the guest retry.

What the server does with an action

Three things can happen to a dispatched action, decided by its name:

ActionHandling
updateConfig { config } / updateData { data }Merged into the module's persistent state and broadcast to every connected view — this is how a Control view writes state.
Built-in module actions (see below)Processed by the matching built-in service (auction, guest list, leaderboard, polling, Q&A, raise-hand, countdown timer, schedule).
Anything elseStored as a response — one row per dispatch, with the payload and server-stamped device attribution.

The built-in services own these action names — avoid reusing them for your own purposes in a custom module: placeBid, buyNow, setProxyBid, itemSold, resetAuction, checkIn, checkOut, undoCheckIn, undoCheckOut, addScore, submitVote, submitQuestion, voteQuestion, moderateQuestion, clearQuestions, raiseHand, lowerHand, acknowledgeHand, clearAllHands, updateRaiseHandSettings, updateTimer, toggleTimerMessage, updateSchedule, setScheduleOverride, lockGroupToStack, fireScheduleBar, linkSheet, unlinkSheet, inspectSheet, cueGo, cueStop, cueGoToIndex. The last three are operator cue-control actions — they reject with an error unless allowCueControl is enabled in the module's config by an operator.

Module state — onState and getState

Every module has one shared, persistent state object with two halves: config (operator settings, usually written by the Control view) and data (accumulated module data). It survives page reloads, is kept per event, and is pushed to every connected view when it changes.

onState(callback)

EventSync.onState(function (state, rev) {
  render(state.config, state.data);
});

The callback receives the full current state and a revision number. It fires shortly after the page loads with the state as it stands, then again on every change. Updates arrive in order — rev only ever increases, and out-of-date snapshots are dropped for you — so it is always safe to render exactly what you're handed. Make your rendering idempotent (redraw from state, don't accumulate).

getState(sinceRev)

const { state, rev } = await EventSync.getState(0);

A one-shot read of the current state. Pass the last rev you've seen, or 0 to just fetch. Resolves with { state, rev }; on hosts without a typed connection the SDK falls back to fetching over HTTP and rev is null. It can reject with { code: 'TIMEOUT' }, so .catch() it. In most modules you won't need this — onState already hands you the current state on load.

Writing state

// From the Control view:
await EventSync.dispatch('updateConfig', { config: { question: 'Cake or biscuits?' } });

State writes are merged, persisted and broadcast — every Index and Results view sees the change through onState within moments.

onEvent — transient notifications

EventSync.onEvent(function (evt) {
  if (evt.action === 'bidUpdate') flashNewBid(evt);
});

onEvent delivers transient, action-shaped payloads broadcast by the server — the "something just happened" channel the built-in modules use for things like live bid updates and countdowns. Events differ from state in every way that matters:

💡 Design rule

Anything a view must be able to show after a reload belongs in state. Use events only for moment-in-time flourishes — a flash, a sound, an animation — that are meaningless a minute later.

Capabilities — native features of the host

Capabilities are native features the hosting app can perform for your module. Support varies by platform, so always gate your UI on capabilities.has() — a call to an unsupported capability rejects with { code: 'UNSUPPORTED' }.

if (EventSync.capabilities.has('scanQR')) {
  scanButton.hidden = false;
}
const names = EventSync.capabilities.list(); // e.g. ['print', 'scanQR']

Host support

CapabilityiPadAndroidDashboard window
print
scanQR
exportCSV
exportJSON
openPhotosFolder
showContentOverlay
setInteractiveRegions
cameraComing soon — rejects UNSUPPORTED everywhere today
photoUploadComing soon — rejects UNSUPPORTED everywhere today

"Dashboard window" means a module window the operator has open on the Dashboard — the export and photos-folder capabilities are not available when a module runs invisibly in the background for a cue. This is exactly what has() reflects, which is why you check rather than assume.

Capability reference

CallBehaviour
capabilities.print(html)Prints the given HTML on the host (e.g. a badge or receipt). Resolves once handed to the host.
capabilities.scanQR()Opens the native QR scanner and resolves with the scanned code as a string. Times out after two minutes if nothing is scanned.
capabilities.exportCSV(filename, rows)Offers a CSV download via the Dashboard's save panel. rows can be an array of arrays (verbatim lines) or an array of plain objects (the header row is derived from the first object's keys). Also accepts (filename, csvString), ({ filename, content }) or just (filename) — with no content, the host exports the module's stored responses table.
capabilities.exportJSON(filename, value)Same shapes as exportCSV, producing pretty-printed JSON. With no content, exports the responses table.
capabilities.openPhotosFolder()Opens the module's photos folder on the Dashboard Mac.
capabilities.showContentOverlay(opts)Puts this module into overlay mode — live content keeps playing underneath your transparent page instead of being stopped. Only device hosts that can composite (iPad / Android) advertise it.
capabilities.setInteractiveRegions(regions)In overlay mode, reports which rectangles of your page take taps — everything outside them passes through to the content beneath. See tap pass-through.

Content overlays — your UI over live content

Normally, showing a module on a device replaces whatever content was playing. An overlay module is different: the device keeps playing its synced content — video, images, a live stream — and composites your module page on top of it. Your page's transparent areas show the content through; your opaque areas (button bars, titles, frames) draw over it.

This is how the built-in Cue Stack Viewer works: guests watch a live cue stack fullscreen, with a strip of view-picker buttons floating along the bottom. Everything it does uses the public SDK below, so you can build your own version — different chrome, different buttons, your branding.

The three layers

LayerWhat draws it
Top — your module pageA transparent WebView. Buttons, labels and frames you draw sit over everything.
Middle — nothing (transparent)Wherever your page has background: transparent and no elements, the content shows through.
Bottom — the live contentThe device's native player: cue-driven video/images, or a LiveSync stream. Keeps running under you, frame-synced with every other device on the stack.

Step 1 — make your page transparent

The host makes the WebView itself see-through, but your page must not paint over the hole:

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  background: transparent !important;   /* the live content shows through */
}

Step 2 — arm overlay mode

Call showContentOverlay once on load (it's idempotent — calling again just updates the options):

EventSync.capabilities.showContentOverlay({
  mode: 'fullscreen',    // 'fullscreen' | 'window' | 'off'
  interactive: true      // your page takes taps (see tap pass-through below)
});
OptionMeaning
mode: 'fullscreen'The content fills the whole screen under your page. Your chrome floats over it.
mode: 'window'The content is confined to a rectangle you choose (see windowed framing) and clipped with rounded corners; your page draws everything around it.
mode: 'off'Back to a normal opaque module — the content layer stops showing through.
rectWindow mode only: { x, y, width, height } as 0–1 fractions of the screen. {x:0.1, y:0.08, width:0.8, height:0.6} puts the content in a centred window across the upper part of the panel.
interactivetrue (default): your page receives taps. false: the overlay is display-only and every tap goes to the content.

Gate on it like any capability — Dashboard windows can't composite, so preview there shows your page over black:

if (EventSync.capabilities.has('showContentOverlay')) {
  EventSync.capabilities.showContentOverlay({ mode: 'fullscreen', interactive: true });
}

Step 3 — tap pass-through: buttons take taps, the feed doesn't

With interactive: true, your page owns every tap — including taps on the "empty" transparent area. Usually you want only your chrome (the button bar) to be tappable. Report the tappable rectangles, in 0–1 viewport fractions, and the host passes everything outside them through:

function reportRegions() {
  var bar = document.getElementById('bar').getBoundingClientRect();
  EventSync.capabilities.setInteractiveRegions([{
    x:      bar.left   / window.innerWidth,
    y:      bar.top    / window.innerHeight,
    width:  bar.width  / window.innerWidth,
    height: bar.height / window.innerHeight
  }]);
}
window.addEventListener('resize', reportRegions);
// …and call reportRegions() again whenever your chrome moves or re-renders.

Passing [] (or never calling it) clears the report — the overlay keeps every tap. Re-measure with getBoundingClientRect() after each render; a requestAnimationFrame(reportRegions) after you update the DOM is the reliable pattern.

Windowed framing — content in a window, buttons under it

Window mode inverts the layout: instead of buttons floating over fullscreen content, the content is confined to a rectangle and your page frames it. The host clips the content layer to your rect (rounded corners, hairline border drawn natively so it's pixel-aligned with the clip) and your page paints everything else — a title above, a row of buttons below:

// Content across the top 62% of the screen, your chrome in the bottom band:
EventSync.capabilities.showContentOverlay({
  mode: 'window',
  rect: { x: 0.05, y: 0.06, width: 0.9, height: 0.62 },
  interactive: true
});

Your CSS then places the chrome in the space that's left — the area below y + height (68% down) is all yours:

#chrome {
  position: fixed;
  left: 0; right: 0;
  top: 70%; bottom: 0;              /* under the content window */
  display: flex; gap: 12px; align-items: center; justify-content: center;
  background: rgba(10, 14, 20, 0.85);
}

Everything else works the same — transparency, tap pass-through, the layers. The window rect and your CSS both speak fractions of the same screen, so keeping them consistent is simple arithmetic.

Letting guests pick their view — selectCueStack

EventSync.selectCueStack(stackId) tunes this device to a different cue stack's live output — the mechanism behind the Cue Stack Viewer's view buttons. Each device picks independently: one guest watches the speaker feed while their neighbour watches the score screen, both frame-synced to everyone else on the same stack.

var ack = await EventSync.selectCueStack(view.stackId);
if (!ack.ok) showToast('View unavailable');   // rejected by a server gate

It is a server-processed action with two gates, both of which an operator controls:

On success the server joins the device to the stack's current output immediately (late-join replay) — the guest doesn't wait for the next cue to see something.

The operator side — config from your Control view

The curated views and the overlay geometry are ordinary module config, written from your Control view like any other setting:

await EventSync.dispatch('updateConfig', { config: {
  views: [
    { label: 'Stage',   stackId: 'a1b2c3…' },
    { label: 'Close-up', stackId: 'd4e5f6…' }
  ],
  overlay: { mode: 'fullscreen', interactive: true }
  // or: overlay: { mode: 'window', rect: { x: 0.05, y: 0.06, width: 0.9, height: 0.62 }, interactive: true }
}});

A module whose config carries an overlay object can even be shown already compositing when an operator locks a group to it — the host reads the config so your page doesn't have to race its showContentOverlay call. Calling it anyway is harmless and covers older configs.

Complete example — a minimal view picker

A working Index view in the Cue Stack Viewer's mould: transparent over the live feed, a bottom button bar built from the operator's curated config.views, taps outside the bar passing through.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="eventsync-fit" content="scroll">
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden;
                 background: transparent !important;
                 font-family: -apple-system, Roboto, sans-serif; }
    #bar { position: fixed; left: 0; right: 0; bottom: 0;
           display: flex; flex-wrap: wrap; gap: 10px; justify-content: center;
           padding: 14px 14px calc(14px + env(safe-area-inset-bottom));
           background: rgba(10, 14, 20, 0.72); backdrop-filter: blur(8px); }
    button { font-size: 1.1rem; padding: 12px 26px; border: 2px solid transparent;
             border-radius: 10px; background: rgba(255,255,255,0.14); color: #fff; }
    button.active { background: #27ae60; }
  </style>
</head>
<body>
  <div id="bar"></div>

  <script>
    var bar = document.getElementById('bar');

    // 1. Composite over the live content instead of replacing it.
    if (EventSync.capabilities.has('showContentOverlay')) {
      EventSync.capabilities.showContentOverlay({ mode: 'fullscreen', interactive: true });
    }

    // 2. Only the bar takes taps; the feed beneath gets everything else.
    function reportRegions() {
      var r = bar.getBoundingClientRect();
      EventSync.capabilities.setInteractiveRegions([{
        x: r.left / window.innerWidth,  y: r.top / window.innerHeight,
        width: r.width / window.innerWidth, height: r.height / window.innerHeight
      }]);
    }
    window.addEventListener('resize', reportRegions);

    // 3. One button per curated view, straight from module config.
    EventSync.onState(function (state) {
      var views = (state.config && state.config.views) || [];
      bar.innerHTML = '';
      views.forEach(function (v) {
        var b = document.createElement('button');
        b.type = 'button';
        b.textContent = v.label || 'View';
        b.addEventListener('click', function () { choose(v.stackId, b); });
        bar.appendChild(b);
      });
      requestAnimationFrame(reportRegions);
    });

    // 4. Tune this device to the tapped view.
    function choose(stackId, b) {
      b.disabled = true;
      EventSync.selectCueStack(stackId)
        .then(function () {
          bar.querySelectorAll('button').forEach(function (x) {
            x.classList.remove('active'); x.disabled = false;
          });
          b.classList.add('active');
        })
        .catch(function () { b.disabled = false; });
    }
  </script>
</body>
</html>

💡 Overlay page checklist

Transparent background on html, body — one opaque wrapper div and the feed disappears. Re-report regions after every render — a button bar that grew a second row keeps taking taps only where the old one was. Respect the safe area — pad the bottom bar with env(safe-area-inset-bottom) so it clears the iPad home indicator. Gate on capabilities.has('showContentOverlay') — in a Dashboard preview window there's no compositor, and your page should still render sensibly over black.

Module state vs responses

EventSync stores two different kinds of module data — pick the right one for each job:

Module stateResponses
ShapeOne shared { config, data } objectOne row per submission
Written bydispatch('updateConfig' / 'updateData', …)Any dispatched action the server doesn't recognise
AttributionNone — it's the module's state, not anyone'sDevice id, name and group, stamped by the server from the connection that sent it — a page cannot submit as another device
Read by modulesonState / getStateNot pushed to modules — aggregate what views need into state
Seen by operatorsLive on every viewThe module's responses in the Dashboard, and exportCSV / exportJSON

In short: state is "what everyone sees right now", responses are "what each guest sent". A survey keeps its question in state and collects answers as responses; if the Results view needs live totals, the Control view (or the guests' dispatches to a counting action) roll them up into data.

Recording engagement — track()

Responses (above) are stored, but they're opaque — the Dashboard's Engagement page can count them as "a submission" but can't see inside them. track() is the typed alternative: you declare the interaction's fields once, then record each occurrence, and it flows into the Engagement page as real, queryable data — most-popular options, sums and averages, funnels and cohorts — attributed to the signed-in guest, all with no extra work on your side.

1. Declare the events

Add a trackedEvents block to your module. Each field has a role that tells Engagement how to treat it:

RoleMeaning
dimensionA category to group and filter by (e.g. drink, option).
metricA number to sum / average / count (e.g. amount, rating).
textFree text — shown in timelines and exports, never aggregated.
{
  "trackedEvents": [
    { "name": "drinkOrder", "label": "Drink ordered",
      "fields": {
        "drink":    { "role": "dimension", "type": "string", "values": ["Negroni","Spritz","Cola"] },
        "quantity": { "role": "metric",    "type": "integer", "min": 1, "max": 20 },
        "notes":    { "role": "text",      "maxLength": 280 }
      } }
  ]
}

For a stored module, put this in the module's config (the Control view can write it via updateConfig). Packaged modules declare it in their manifest.

2. Record each one

await EventSync.track('drinkOrder', { drink: 'Negroni', quantity: 1, notes: 'no ice' });

That's it. The server validates the fields against your declaration (an undeclared field, or a value outside the declared range, is rejected), stamps the verified guest, device, and time itself — a page can never fake who did something — and stores it. It appears in the Engagement page immediately.

Identity is never yours to set

You don't send who the guest is — the server resolves it from the authenticated connection. That's what makes the numbers trustworthy: "47 orders from 31 people" is counted at read time from the verified guest on each row, not from anything the page claimed.

💡 track() vs a response

Use track() when you want the interaction to show up as analysable engagement (charts, funnels, per-guest timelines with real detail). Use a plain response for free-form submissions you'll just export. A module can use both. Your module's own live logic (tallies, "has this guest already entered?") still belongs in statetrack() is a passive, append-only log, not your module's working data.

Creating a module in the Dashboard

Modules live under Curate → Modules. Click Create Module:

Paste complete HTML documents into each tab — remember, don't include any EventSync script; the SDK is injected for you. The Preview button renders the current tab, and you can re-open the editor at any time from the module card's Edit.

Alternatively, toggle Use External URL to load the module from a website instead of stored HTML, in Proxied (server fetches and caches — devices stay air-gapped) or Direct mode. See Custom modules for the trade-offs.

Assets

Each custom module has its own asset store for images, fonts, scripts and other files. Open Assets from the module card, upload files, and reference them from your HTML with a relative path:

<img src="assets/logo.png">

Assets are distributed with the module, so they work on devices without internet. The dialog's copy button gives you the exact URL for any uploaded file.

Complete example — a vote button

A minimal but complete Index view: renders the question from module state, submits one vote per tap as a response, and handles the acknowledgement properly (disable while in flight, confirm on success, re-enable on failure).

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { display: flex; flex-direction: column; align-items: center; justify-content: center;
           height: 100%; margin: 0; font-family: -apple-system, sans-serif;
           background: #101828; color: #fff; text-align: center; }
    h1 { font-size: 2.4rem; margin: 0 24px 32px; }
    button { font-size: 1.6rem; padding: 20px 56px; border: none; border-radius: 14px;
             background: #00d4aa; color: #06281f; font-weight: 700; }
    button:disabled { opacity: 0.45; }
    #status { min-height: 1.5em; margin-top: 20px; color: #8fe6cf; }
  </style>
</head>
<body>
  <h1 id="question">Loading…</h1>
  <button id="voteBtn" type="button">Vote YES</button>
  <p id="status"></p>

  <script>
    var btn = document.getElementById('voteBtn');
    var status = document.getElementById('status');

    // The operator sets the question on the Control view via
    // dispatch('updateConfig', { config: { question: '…' } }).
    EventSync.onState(function (state, rev) {
      document.getElementById('question').textContent =
        (state.config && state.config.question) || 'Cast your vote';
    });

    btn.addEventListener('click', function () {
      btn.disabled = true;
      status.textContent = 'Sending…';

      // 'vote' isn't a built-in action, so the server stores each dispatch as a
      // response, attributed to this device — one row per guest tap.
      EventSync.dispatch('vote', { choice: 'yes' })
        .then(function (ack) {
          status.textContent = ack.legacy
            ? 'Vote sent!'                   // older host: sent, not confirmed
            : 'Vote counted — thank you!';   // server has stored it
        })
        .catch(function (err) {
          status.textContent = 'Vote failed — please try again ('
            + (err.error || err.code || 'error') + ')';
          btn.disabled = false;              // let the guest retry
        });
    });
  </script>
</body>
</html>

Paste that into the Home tab of a new module, show the module's Index view on a group, and watch the votes arrive in the module's responses — then capabilities.exportCSV('votes.csv') from a Control view (or the Dashboard's own export) to take them home.