Live polls and audience Q&A for talks, classrooms and all-hands. No sign-ups, no apps, no per-seat pricing.
You put a QR code on the screen. The room scans it, votes, and asks questions from their seats. Results move on the big screen while you talk. Nobody installs anything, nobody creates an account, and there is no server behind it that can go down or read your data.
| Landing | Host console | Projector |
|---|---|---|
![]() |
![]() |
![]() |
| Audience: polls (mobile) | Audience: Q&A (mobile) |
|---|---|
![]() |
![]() |
- Open handraise.space, name the occasion, and hit create a room. Your browser asks for a passkey (Face ID / fingerprint / PIN); that passkey is your host identity, so there is no password and nothing to sign up for.
- Hit projector ↗ and put that window on the big screen. It shows a QR code and a 6-character room code.
- People scan the code with their phone camera and they're in.
- Launch polls from the host console: 2 to 6 options, live results, close and reopen voting whenever you like. People can change their vote while a poll is open, and each person gets exactly one live vote.
- Audience questions land in your console first. Approve the good ones and they appear on the projector, ranked by upvotes. Mark them answered as you go.
Good to know:
- Hosting from another device: open your room's link on any device where your passkey syncs (iCloud Keychain, Google Password Manager) and hit "unlock with passkey".
- Hosting needs a modern browser with passkey support. Attending works everywhere.
- Rooms are ephemeral: they expire after 7 days. Handraise is for live moments, not archives.
- It's free. There is no server to pay for, so there is nothing to charge you for.
Handraise is a static React app with no backend. Everything that would normally be a server (realtime pub/sub, storage, identity) is replaced by the nostr protocol, used purely as infrastructure; the product never surfaces it:
- Realtime + storage: rooms, polls, votes, questions and moderation decisions are signed events published to public relays (plain websockets). Every client subscribes to one filter per room and reduces the event stream into room state. The full wire format is specced in PROTOCOL.md; anyone can build a compatible client, overlay, or exporter against a live room.
- Host identity: derived from a passkey via the WebAuthn PRF extension. The PRF output for a fixed app salt is hashed into a secp256k1 key, so the same passkey always re-derives the same identity, on any device it syncs to. No fallbacks: without passkey + PRF support you can attend but not host.
- Attendee identity: a disposable keypair generated silently per browser. Losing it costs nothing.
- One vote per person falls out of the protocol: votes are addressable events keyed per (voter, poll), so a re-vote replaces the old one and deduplication is free.
- Link integrity: every generated link and QR carries the host's pubkey (
?h=), so clients resolve rooms by (author, code) and a squatter can't hijack a room code. Hand-typed codes fall back to earliest-claimant discovery.
Participants never talk to each other. Every client (host console, each phone, the projector) holds websocket connections to the same small set of relays, which are dumb store-and-forward servers: they keep events and push them to anyone whose subscription filter matches.
sequenceDiagram
participant H as host console
participant R as relays (n)
participant A as attendee phone
participant P as projector
Note over H,P: everyone subscribes to the room filter on the same relay set
H->>R: publish poll (signed event)
R-->>A: push poll
R-->>P: push poll
A->>R: publish vote (signed event)
R-->>H: push vote
R-->>P: push vote
Note over P: bar moves, no server in sight
The mechanics, concretely:
- Publishing: the client signs an event and sends it to every relay in the set in parallel. One acceptance counts as success (the load test above shows why); a toast surfaces total failure.
- Subscribing: on joining a room, each client opens a subscription for the room's
filter (
["a", <room address>]). The relay first sends everything it has stored, then an end-of-stored-events marker, then streams new matches live. That backfill is why a projector opened mid-talk, or a phone that reconnects after the elevator, shows the complete room state, not just what happens next. - Convergence: there is no ordering guarantee across relays, so the client reducer is
built to not need one. Events are deduplicated by id; replaceable events (polls, votes,
moderation) converge on the highest
created_atper slot; counting is derived from the final state. Any client that has seen the same set of events, in any order, renders the same room. - The one requirement: writer and reader must share at least one relay. Handraise
ships one default set to every client, so this always holds;
VITE_RELAYSoverrides it for self-hosted deployments. - Lifecycle: every event carries an expiration tag (7 days), after which relays are free to delete it and the room evaporates.
- The projector's Q&A is host-moderated, so question spam never reaches the screen.
- Votes are one-per-key; identities are free, so a motivated attacker can still stuff a poll from a script. For low-stakes conference polling this matches the incumbent tools' anonymous mode. For hostile venues, run your own relay (below) with per-IP rate limits.
- The host's derived key lives in memory and sessionStorage for the tab session only.
- Relay operators see room content (it's a public-audience tool; treat it that way).
The default relay set is ordered by measured behavior under load.
scripts/relay-load-test.mjs simulates a 150-voter, 20-question burst from a single IP:
nos.lol and relay.primal.net stored 100% of events; relay.damus.io rate-limited nearly
all of them and rides along best-effort. Union delivery across the set was complete, and
the client only needs one relay to accept a publish.
npm install
npm run dev # local dev server
npm run build # type-check + production build to dist/- React + TypeScript + Vite, nostr-tools for transport, zero backend services.
- Deploys anywhere static files go.
vercel.jsoncarries the SPA rewrite. - The passkey flow is end-to-end testable in Chromium via a CDP virtual authenticator
(
WebAuthn.addVirtualAuthenticatorwithhasPrf: true).
src/
lib/protocol.ts event kinds, builders/parsers, room-state reducer (pure)
lib/nostr.ts relay pool + key custody (the only file that knows the transport)
lib/passkey.ts WebAuthn PRF key derivation
lib/actions.ts user intents (createRoom, castVote, moderate…)
hooks/useRoom.ts room resolution + live subscription -> RoomState
pages/ Landing, Audience, Host, Projector
components/ PollResults, Logo, Toasts
By default rooms travel through public relays. To keep everything on your own infrastructure, run any nostr relay, e.g. a single strfry container, and build with:
VITE_RELAYS=wss://relay.your-company.comRoom data then never leaves your network, and strfry's write-policy plugin can whitelist handraise's event kinds and rate-limit per IP, which is the real defense for large or hostile rooms.
"Show of hands": sun yellow (#ffc42e) + warm ink (#17150f) on warm white, yellow used
only as an accent (one highlighter sweep, active states, the room-code chip). The
projector runs a dark "hall" theme built for dark rooms and back rows. Data colors appear
only in poll bars. All UI copy is lowercase; user content renders as typed. Type:
Bricolage Grotesque + Instrument Sans, self-hosted. The mark is the raised open palm from
Phosphor Icons (MIT).
- Quizzes with leaderboards
- Word clouds
- Bundled one-container relay image + policy script for self-hosting
- Optional locked rooms (join secret in the QR) for hostile venues




