The one idea
Two paths to the browser, and they never touch
Everything else follows from this. The conversation goes straight from the bot platform to the browser over its own transport. The screen state takes a completely separate route: the flow calls an HTTP endpoint, a Worker signs and forwards it to Pusher, and the page reacts to a pushed event.
The temptation is to skip the second path and just watch the bot's replies in the browser — if the bot says "you're now logged in", show the dashboard. Don't. Text-matching a bot's own words is the single decision that will cost you the most time later.
| Part | Path | What it actually does |
|---|---|---|
| Static site | Both | One index.html per tenant, served from a Cloudflare Worker's asset directory. Holds the markup, the palette, the state machine and both widgets. |
| Relay Worker | Control | Accepts POST /state, checks the state name against a whitelist, signs a Pusher REST call, forwards it. Exists so the Pusher secret never leaves Cloudflare. |
| Pusher Channels | Control | Fans the event out to every open page. One app, one public channel, one event name for the whole estate. |
| Cognigy flow | Both | Runs the conversation and calls the relay at each screen beat. The same flow is bound to a chat endpoint and a voice endpoint. |
| Widgets | Conversation | A hand-built chat panel over Cognigy's Socket.IO endpoint, and the vendor WebRTC call widget hidden behind your own button. |
Layer Hosting
Static assets on a Worker, not Pages
The whole estate is one repo with one wrangler.toml that declares an asset directory and no main entry point. There is no Worker script for the site, no functions directory, no redirects file, no CI workflow.
name = "demo-estate"
compatibility_date = "2026-06-17"
[assets]
directory = "./public"
Connect the repo to Cloudflare's Git integration and a push to main publishes in well under a minute. A new tenant is a new directory:
public/
index.html → /
waverley/index.html → /waverley
waverley/img/…
townsville/index.html → /townsville
Two things to know. Paths are case-sensitive — /waverley resolves and /Waverley returns a blank page, which is a memorable way to lose five minutes in front of a customer. And a directory URL without the trailing slash answers 307 to the slashed form, so browsers are fine but curl without -L looks like a failure.
Control path Signalling
The relay Worker is a signing proxy with a whitelist
Pusher's REST API needs your app secret plus a per-request signature. You do not want that secret sitting in a bot-platform HTTP node, and most flow builders can't compute the signature anyway. So a small Worker does both jobs and exposes something a flow can call with a plain JSON body.
Give it three endpoints and nothing more:
GET /health— echoes the channel, the event, which secrets are bound, and the full list of allowed states. This is the endpoint you will actually use to debug, so make it verbose.POST /state— the one the flow calls.POST /reset— convenience, pushes the reset state with no payload.
POST https://relay.<your-subdomain>.workers.dev/state
Content-Type: application/json
{
"state": "pet-logged",
"selections": "ticket: 41182 | pet: Milo | animal: Dog"
}
→ 200 { ok: true, state, channel, event, pusher_status: 200 }
→ 400 { error: "Invalid state", allowed_states: [ … ] }
The whitelist is the important part. It is a Set of every state name the estate understands, and an unknown name is rejected with a 400 that lists what is allowed. That turns the most common flow-authoring mistake — a typo in a state name — into an immediate, self-explaining error instead of a page that silently doesn't move.
const PUSHER_CHANNEL = "demo-state"; // one channel for the estate
const PUSHER_EVENT = "state-change"; // one event name
const VALID_STATES = new Set([
"authenticated", "resident-ready", "rates-ready",
"pet-confirm", "pet-logged",
"bin-confirm", "bin-logged",
"reset", "home",
]);
// Pusher REST auth = querystring of auth_key / auth_timestamp /
// auth_version / body_md5, sorted, then HMAC-SHA256 over
// `POST\n/apps/{id}/events\n{params}` with the app secret.
Workers have WebCrypto, which has HMAC-SHA256 but no MD5. Pusher's signature includes an MD5 of the request body, so you have to ship a ~60-line MD5 implementation inside the Worker. It's the only genuinely annoying part of the build. Budget twenty minutes and move on.
Configuration and secrets
Four bindings: PUSHER_APP_ID, PUSHER_KEY and PUSHER_CLUSTER as plain text, PUSHER_SECRET as a secret. Read them off env rather than hardcoding, and have the Worker fail loudly with a list of what's missing.
Keep CORS wide open (Access-Control-Allow-Origin: *) and handle OPTIONS. The flow calls this server-side so it doesn't need CORS, but you will want to fire states from a browser console and from curl during rehearsal.
Pusher setup
The free sandbox plan is comfortably enough. Create one app, note the cluster (it must match your region — ap4 for Sydney), and use a public channel so you need no auth endpoint. The page only needs the app key and the cluster, both of which are safe in client source; the secret stays in the Worker.
Control path Payload contract
One generic payload, pipe-delimited
Every state carries the same two fields. state selects the screen; selections is an optional flat string of key: value pairs separated by pipes.
ticket: 277174985192 | report: Illegal parking |
vehicle: ABC12D - Blue Ford Falcon |
address: 18 Curlewis Street, Bondi NSW 2026 |
photo: Parked diagonally across two marked bays
Pipes rather than JSON, deliberately. Flow authors compose these by hand in a text field, often stitched together from tool outputs, and a pipe-delimited string survives that with no escaping and no quoting to get wrong. JSON in a flow text field turns one missed brace into a broken demo.
The cost is two rules you have to hold: a value can never contain a pipe, and the parser must split each pair on its first colon only, so timestamps and unit numbers inside values survive. Write the parser once and reuse it for every state:
function parseFields(raw){
const out = {};
if (!raw) return out;
String(raw).split('|').forEach(pair => {
const i = pair.indexOf(':'); // FIRST colon only
if (i < 0) return;
const k = pair.slice(0, i).trim().toLowerCase();
const v = pair.slice(i + 1).trim();
if (k && v) out[k] = v;
});
return out;
}
// Per-state defaults, so a partial payload still renders
// complete-looking copy instead of blanks or "undefined".
const FALLBACKS = {
pet: { ticket:'pending', pet:'Milo', animal:'Dog', … },
};
const fields = (type, raw) =>
Object.assign({}, FALLBACKS[type] || {}, parseFields(raw));
That fallback merge is what makes the rig demo-safe. A flow that fires a state with an empty or half-built payload renders a tidy screen with neutral placeholders — never an exception, and never the raw payload echoed onto the page.
Layer Screen state
The page is a state machine, not a set of URLs
Each tenant is a single self-contained HTML file — inline CSS, inline JS, no bundler. Inside it, every screen is a <main class="page"> block and exactly one carries .active. "Navigation" is a class toggle, which is why the page can change under the customer without a reload and without dropping the conversation.
<main id="page-landing" class="page active">…</main>
<main id="page-resident" class="page">…</main>
<main id="page-rates" class="page">…</main>
.page{ display:none } .page.active{ display:block }
function showPage(el){
[landing, resident, rates].forEach(
p => p.classList.toggle('active', p === el));
window.scrollTo(0,0);
}
One dispatcher receives every event and is the only place that decides what the screen does:
function applyState(data, source){
const state = norm(data && data.state); // trim, lowercase, dashes
const sel = data && data.selections;
if (state === 'reset' || state === 'home') return resetHome();
// Arming guard — see below.
if (KNOWN.includes(state) && source === 'pusher') armed = true;
if (!armed) return console.warn('ignored unarmed state:', state);
if (state === 'authenticated') showResident();
else if (state === 'rates-ready') showRates();
else if (state === 'pet-confirm') showConfirm('pet', sel);
else if (state === 'pet-logged') showLogged('pet', sel);
else console.warn('unrecognised state:', state);
}
Three patterns worth copying
The arming guard. Ignore pushed states until a conversation has actually begun — armed either by the first recognised state or by the customer speaking or typing. Without it, a stray event from someone else's rehearsal jumps your demo forward while you're still on the intro slide.
Confirm then log. Give each use case two states. <thing>-confirm opens a modal listing the parsed fields with Confirm and Change a detail buttons — and those buttons don't call an API, they inject an utterance back into the chat so the bot drives the next step. <thing>-logged then renders the success card with the real reference number. It reads as a genuine two-way transaction because it is one.
Manual drive and an on-page monitor. Expose the dispatcher on window so you can rehearse the entire demo with no bot attached, and gate a live event monitor behind a query string:
window.demoState = (state, selections) =>
{ armed = true; applyState({state, selections}, 'manual'); };
// …then, in the console:
demoState('pet-logged', 'ticket: 41182 | pet: Milo');
// ?debug=1 renders a fixed panel logging every received
// event with its raw payload. Being able to point at
// "Pusher connected, nothing arrived" ends the
// is-it-the-bot-or-the-page argument instantly.
Conversation path Widgets
Build the chat UI; hide the vendor's
The stock webchat can't be brand-skinned far enough to pass as a council or a retailer's own site, so talk to the Socket.IO endpoint directly and render the transcript yourself. It's roughly 150 lines.
// Endpoint URL is https://<host>/<urlToken>
const socket = io(origin, {
query: { urlToken, URLToken: urlToken, userId, sessionId },
auth: { URLToken: urlToken, userId, sessionId },
transports: ['websocket'], forceNew: true,
});
socket.emit('processInput',
{ URLToken: urlToken, userId, sessionId, text, data: {} });
socket.on('output', d => {
const p = d.data || d;
render(p.text);
// quick replies live deep:
// p.data._cognigy._webchat.message.attachment.payload.quick_replies
});
socket.on('finalPing', hideTypingIndicator);
Generate a fresh userId and sessionId per conversation — that, not clearing the transcript, is what makes a genuinely new session for the next run-through. Add a short welcome timer that posts your own opener if the flow hasn't greeted within a couple of seconds, so the panel is never empty when someone opens it.
Human handover
If the demo hands off to a live agent, style human messages differently from the bot — a different header colour and a labelled bubble — so the audience can see the moment it happens. Detect it from explicit sender or source metadata on the payload if the platform provides it, and fall back to matching the transfer wording. Never treat the first ordinary message after a transfer request as proof a human arrived; that mislabels the bot's own replies as a person. Stay in a visible "connecting" state until you have real evidence.
Voice
Load the vendor click-to-call WebRTC widget, then hide it and drive it from your own button. Two non-obvious details:
- The widget takes the
https://endpoint URL and derives its ownwss://…/voiceGatewaysocket. Passing thewss://form looks reasonable and silently fails. - It injects DOM into
document.bodyon init. Watch with aMutationObserver, move anything new into an off-screen mount, then find its call button and.click()it from your own control.
Arm the page-state handling on your button press, before the widget has finished loading. Otherwise the first pushed state can arrive while the hidden control is still initialising and get dropped, which presents as "voice works but the screen never moves on the first attempt".
Layer Branding a tenant
Clone the file, re-token the palette
A new prospect is a copy of the closest existing tenant with a new :root block. Drive every colour through custom properties from the start and this takes an hour rather than a day.
Don't eyeball the brand from screenshots. Open the prospect's real site and read the computed styles out of the live DOM — you get their exact hexes, their nav structure and their font stack in one pass:
const seen = {};
document.querySelectorAll('*').forEach(el => {
const r = el.getBoundingClientRect();
if (r.width * r.height < 4000) return; // ignore small stuff
const cs = getComputedStyle(el);
[cs.backgroundColor, cs.color].forEach(c => {
if (c && !c.includes('rgba(0, 0, 0, 0)')) seen[c] = (seen[c]||0)+1;
});
});
Object.entries(seen).sort((a,b) => b[1]-a[1]).slice(0,20);
Then match their structure, not just their hues — the utility bar, the nav strip, where the search sits. A page with the right blue but the wrong furniture still reads as a template.
Two things that matter more than they sound:
- Reuse the illustrations across tenants. Only the logo and the hero image are genuinely tenant-specific. Regenerating a fresh set of use-case images per prospect buys nothing and costs consistency.
- Get the domain model right, not just the branding. If you clone a Queensland council demo for a New South Wales one, the rates model is different — different notice cycle, no water and sewerage on the council bill, lifetime pet registration rather than annual. A subject-matter audience forgives an approximate shade of blue and never forgives a line item that couldn't exist on their bill.
Brand fonts are usually licensed and not on a CDN. Pick the nearest Google font, declare a real fallback stack, and don't lose time on it.
Sequence Build order
The order that avoids rework
Each step is testable on its own, which matters because a broken rig has four places to hide.
-
Pusher app
Create it, note the app id, key, secret and cluster. Decide the channel and event name now and never change them.
-
Relay Worker
Deploy it with the four bindings and a whitelist containing a single test state. Confirm
GET /healthreports all four secrets bound. -
Prove the pipe end to end
Open Pusher's debug console,
curla state at the Worker, and watch the event land. Do not write a line of page code until you have seen this. -
Static site skeleton
One
wrangler.toml, onepublic/<tenant>/index.html, Git integration connected. Get a push-to-live loop working while there's nothing to break. -
Page state machine
Screens,
showPage,applyState, the Pusher client, the arming guard,window.demoStateand the?debug=1monitor. Drive the whole demo from the console with no bot connected. -
Chat widget
Socket.IO against the flow's chat endpoint. Verify a round trip and quick replies before styling anything.
-
Flow wiring
Add the HTTP node and call it at every screen beat — authenticated, each confirm, each logged, and reset at the end. Bind the same flow to a voice endpoint.
-
Voice widget, then brand
Voice last because it's the fiddliest and it exercises everything else. Re-token the palette only once the mechanics are proven.
Field notes Gotchas
The ones that actually cost time
-
The screen advances on the bot's wording
Never infer state from bot text. One prompt tweak and the demo desynchronises, and voice and chat drift apart because the phrasing differs. Pushed events only.
-
Invalid state · 400 from the relay
Add the state to the Worker's whitelist before testing it in the page. This is the correct behaviour working as designed, and it will still catch you every time.
-
Secrets vanish after redeploying the Worker
Uploading a script via the Cloudflare API replaces its bindings. Send
keep_bindings: ["plain_text","secret_text"]in the metadata, or setkeep_varsif deploying with wrangler. Always re-read the bindings afterwards to confirm. -
No such module: worker.js
On a multipart API upload, Cloudflare resolves
main_moduleagainst the part's filename, not its form field name. Setfilename=worker.jsexplicitly. -
Voice connects but the page never moves
Either the widget was handed a
wss://URL instead ofhttps://, or state handling wasn't armed until after the widget loaded and the first event was dropped. Arm on your own button press. -
Every open demo page reacts at once
One shared channel means a state fired for one tenant also reaches any other tenant page you have open. Harmless — unrecognised states are ignored — but close the other tabs before a live session, or split channels per tenant if you run demos in parallel.
-
A value with a pipe in it truncates the card
The delimiter has no escape. Strip or substitute pipes in the flow before composing
selections, especially in free-text fields and anything generated from a tool result. -
The chat resets when the page changes
Keep the widget markup outside the screen containers so a screen swap can't touch it, and never reload or re-init the socket on a state change. A confirmation screen is a visual event, not a new session.
-
/Tenant returns a blank page
Asset paths are case-sensitive. Keep every directory lowercase and never hand out a mixed-case demo URL.
-
The Worker's source lives only in Cloudflare
Easy to skip because the dashboard editor works fine. Commit the Worker to a repo on day one — pulling a deployed script back out of the API to make a one-line change is a bad afternoon.
Prerequisites Accounts
What you need before starting
- Cloudflare — free plan covers the static assets and the relay Worker. A custom domain is optional but makes the demo read as real.
- Pusher Channels — free sandbox plan; the message volume of a live demo is trivial.
- Cognigy.AI — a trial or partner tenant. You need one flow with two endpoints bound to it: a Socket.IO chat endpoint and a Voice Gateway endpoint.
- A backend to make it real — the moment the bot returns a genuine reference number from a real CRM or ticketing system rather than a fabricated one, the demo stops being a mockup. This is the single highest-value thing to add once the rig works.
Time to a working rig for someone who has done this before: about a day for the first tenant, then an hour or two per tenant after that.