Skip to content

Remote relay

A deck built with kadal-deck build and put on a static host (GitHub Pages, Netlify, Vercel, S3, a plain file server) has no live room and no dev server. The presenter view and the phone remote are on two devices, so something has to carry messages between them. kadal-deck relay is that something: a small WebSocket server that pairs one phone with one presenting window and passes their messages along.

The phone remote and the presenter view are on two devices, so something carries messages between them: the deck's live room for a deck published on deck.kadal.cc, the dev server for kadal-deck dev on your laptop, or kadal-deck relay for a static build on GitHub Pages, Netlify, S3 or any file server. The phone pairs by scanning the QR code in the presenter view.your phone/remotepresenterviewthe deck's live roompublished on deck.kadal.ccthe dev serverkadal-deck devkadal-deck relaya static build, anywhereororscan the QR inthe presenter view

Terminal window
npx kadal-deck relay
Terminal window
pnpm exec kadal-deck relay
Terminal window
yarn kadal-deck relay
Terminal window
bunx kadal-deck relay
relay ws://localhost:4174/ ws://192.168.1.20:4174/
password af35-f2qp-qdu8 (made up; --password <p> sets your own)
the presenter view's Phone remote asks for the password once; a phone needs only the QR
point a build at it: remote: { relay: "ws://192.168.1.20:4174/" } in the deck,
or ?relay=ws://192.168.1.20:4174/ on its presenter view's address
(Ctrl+C stops)

You don’t need a relay for a deck published on deck.kadal.cc (its room pairs the phone) or for kadal-deck dev (the dev server does). For the steps end to end, see Present from your phone, even on a static host.

Option Default Meaning
--port <n> 4174 the port to listen on
--host <addr> 0.0.0.0 (127.0.0.1 with --tunnel) the address to listen on; 0.0.0.0 is every network interface
--password <p> KADAL_DECK_RELAY_PASSWORD, else made up the password a presenting window must give
--tunnel off also put it on the internet through a Cloudflare Quick Tunnel and print its wss:// address
--json off print one JSON object once the relay (and its tunnel) is up

Ctrl+C stops the relay and closes its tunnel. The relay keeps its pairings in memory, so after a restart the phone has to scan the QR code again.

Either name the relay in the deck, which every build then uses:

---
title: My talk
remote:
relay: wss://relay.example.com/
---

or leave the deck alone and add ?relay= to the presenter view’s address:

https://you.github.io/my-talk/presenter?relay=wss://relay.example.com/

?relay= wins over the deck’s setting and is kept for that browser tab, so the windows the presenter view opens (the share window, the notes) use it too, and the QR code carries it to the phone. ?relay= with nothing after it forgets it. An https:// address is read as wss://, and http:// as ws://.

A page served over https may only open a wss:// relay. Use ws:// addresses only for a deck you open over plain http, such as kadal-deck serve on your own network.

Only a presenting window needs the password; a phone needs only the code in the QR, exactly as on kadal-deck dev.

  • The first time the presenter view opens the Phone remote dialog, it asks for the relay’s password and keeps it for that tab.
  • The password comes from --password, else the KADAL_DECK_RELAY_PASSWORD environment variable, else the relay makes one up (three groups of four letters and digits) and prints it.
  • Wrong passwords are limited to 10 a minute from one address, and 100 a minute in all. After that the dialog says “Too many wrong passwords. Wait a minute.”

The password stops anyone else from using your relay to drive a window, or from running their own talk through it. The pairing itself works as everywhere else: a code that works once, changes every 30 seconds, one phone at a time.

Nothing but the remote’s messages. It holds no deck, no notes, no drawings and no editing endpoints. A plain HTTP request to its root gets one line saying what it is; any other path is a 404. Messages are text frames of at most 64 KiB, and it pings every connection every 25 seconds to notice a dropped one.

Commands a phone sends run in the presenting window, which refuses what a phone may not do: open windows, go full screen, or pair and unpair phones.

For a talk in a room where your laptop and phone share a network, run the relay on the laptop and use the address it prints for that network:

Terminal window
npx kadal-deck relay --password stage-door
Terminal window
pnpm exec kadal-deck relay --password stage-door
Terminal window
yarn kadal-deck relay --password stage-door
Terminal window
bunx kadal-deck relay --password stage-door

Serve the build on the same network (for example npx kadal-deck serve dist --host 0.0.0.0pnpm exec kadal-deck serve dist --host 0.0.0.0yarn kadal-deck serve dist --host 0.0.0.0bunx kadal-deck serve dist --host 0.0.0.0) and open the presenter view with ?relay=ws://<laptop address>:4174/. The phone points by touch; the air pointer needs https.

For a deck on an https host, the relay must be wss://. The quickest way is a Cloudflare Quick Tunnel, which needs cloudflared installed and no account:

Terminal window
npx kadal-deck relay --tunnel
Terminal window
pnpm exec kadal-deck relay --tunnel
Terminal window
yarn kadal-deck relay --tunnel
Terminal window
bunx kadal-deck relay --tunnel
tunnel wss://example-words-here.trycloudflare.com/ (a Cloudflare Quick Tunnel: for a deck on https)
point a build at it: remote: { relay: "wss://example-words-here.trycloudflare.com/" } in the deck,
or ?relay=wss://example-words-here.trycloudflare.com/ on its presenter view's address

A Quick Tunnel gets a new address every time it starts, so pass it with ?relay= rather than building it into the deck.

For an address that doesn’t change, run the relay on a small server behind a reverse proxy that terminates TLS and forwards WebSocket upgrades, and set the password from the environment:

Terminal window
KADAL_DECK_RELAY_PASSWORD='a long passphrase' npx kadal-deck relay --host 127.0.0.1 --port 4174
# nginx: wss://relay.example.com/ → the relay
location / {
proxy_pass http://127.0.0.1:4174;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}

Then put remote: { relay: "wss://relay.example.com/" } in the deck. One relay serves any number of decks and talks at once: each presenting window gets its own codes and its own phone.

With --json, the human-readable lines go to stderr and stdout carries one object when the relay is ready:

{
"ok": true,
"urls": ["ws://localhost:4174/", "ws://192.168.1.20:4174/"],
"tunnel": null,
"password": "af35-f2qp-qdu8"
}

password is null when you gave one (it is never echoed), and tunnel is the wss:// address with --tunnel. The command keeps running until it is stopped. Exit codes are the CLI’s: 2 for a bad flag (an empty --password), 4 when --tunnel can’t find cloudflared.

Send feedback