Participants scan a QR code, submit topics, and upvote from their phones. The app picks the top-voted topic, runs a server-authoritative timer, and polls the room to keep going or move on — while a built-in voice moderator reads each topic aloud and calls time.
- What is Lean Coffee?
- Features
- Quick start
- How it works
- Running for remote attendees
- Configuration
- Architecture
- Project structure
- Development
- Testing
- Roadmap
- Contributing
- Security
- License
Lean Coffee is a structured-but-agenda-less meeting format. People propose topics, vote on what to discuss, and work through them one at a time in short timeboxes. When a timer ends the group does a quick thumbs vote: keep going or move on. It keeps conversations focused and democratic — everyone's topic gets a fair shot at the table.
This app runs that ritual for a hybrid room: a facilitator drives a Host Console (great on a projector / shared screen), and everyone else joins from a phone or laptop with no login.
- 🗳️ Submit & upvote topics in real time — one vote per topic, unlimited topics, live re-sorting
- ⏱️ Server-authoritative timers — every device counts down in perfect sync; survives refreshes & reconnects
- 🙋 Keep-going / move-on polls — opens automatically at time's-up, with a live tally
- 📱 Frictionless join — scan a QR or open a link; anonymous, optional display name
- 🌍 Remote-ready by default — one command opens a public Cloudflare tunnel so remote folks join while you screen-share (no account, no config)
- 🗣️ Voice moderator (host only) — reads each topic aloud and announces "time's up" using the browser's speech synthesizer
- 🧲 Auto-scroll — the Host Console keeps the active topic + timer in view as the discussion advances
- ♻️ Concurrent by design — add and upvote topics any time, even mid-discussion
- 🎨 Clean, modern UI — light/dark, mobile-first participant view, projector-friendly host view
- 💾 Zero-setup persistence — embedded SQLite; a laptop sleep or restart recovers the session
Prerequisites: Node.js ≥ 20. For remote attendees,
cloudflared(e.g.brew install cloudflared) — optional; the app falls back to your LAN address without it.
git clone https://github.com/joehunt/lean-coffee.git
cd lean-coffee
npm install
npm startOpen the printed Host Console URL (http://localhost:3000/host/<id>?t=<token>), put it on the
shared screen, and have everyone scan the QR. That's it.
npm start— builds the web app, boots the server on:3000, and opens a public tunnel so remote attendees can join.npm run start:local— same, but LAN-only (no tunnel).
Create a session ──▶ Share the QR / link ──▶ Collect & upvote topics
│ │
▼ ▼
Start discussion ◀── Pick the top-voted topic ◀── (anyone, any time)
│
▼
Run the timebox (default 5 min) ──▶ Time's up ──▶ "Keep going?" poll
│ │
└────────── Extend (+2 min) ◀── facilitator ──▶ Next topic
As the facilitator you: create the session, open submissions, start the discussion, and use the stage controls (pause, ±1 min, extend, next, start poll). The app auto-selects the top-voted topic and runs everything else.
As a participant you: scan the QR, optionally type a name, add topics, upvote the ones you care about, and tap Keep going / Move on when a poll is live — all from your phone.
npm start opens a Cloudflare Quick Tunnel
automatically — no account, no signup, no config:
- The server spawns
cloudflaredand parses the publichttps://<random>.trycloudflare.comURL. - The Host Console's QR and join link switch to that public URL (the card shows "Public tunnel live").
- Anyone you share your screen with on Zoom / Webex / Meet can scan the same QR and join over the internet.
If cloudflared isn't installed or can't connect, the app gracefully falls back to your LAN
address (the card shows "Local network") — perfect for same-room/same-Wi-Fi sessions. Use
npm run start:local to skip the tunnel entirely.
Note: Quick Tunnel URLs are ephemeral (a new one each run) — ideal for ad-hoc sessions. For a stable recurring URL, a named Cloudflare tunnel (free account) is a natural opt-in.
Environment variables:
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP/WebSocket port |
DB_PATH |
data/lean-coffee.db |
SQLite database file |
TUNNEL |
1 (on) |
Set TUNNEL=0 to disable the Cloudflare tunnel |
Per-session settings (chosen on the create-session screen, or via the API):
| Setting | Default | Description |
|---|---|---|
defaultTimerSec |
300 (5 m) |
Length of each discussion timebox |
extensionSec |
120 (2 m) |
Length added by Extend |
maxVotesPerSession |
null |
Optional cap on distinct topics one person can upvote (unlimited) |
The whole app is one Node process: Fastify serves the built React SPA and the REST endpoints, Socket.IO handles realtime, and SQLite stores state — no external services required.
The server is the single source of truth. Clients are thin renderers: they send intents
(topic:add, host:startDiscussion, …) and receive authoritative broadcasts (session:snapshot,
round:updated, …). Timers live on the server as absolute endsAt timestamps; clients compute the
remaining time from endsAt plus a clock-offset handshake, so every screen stays in lockstep and
reconnects heal automatically.
| Layer | Technology |
|---|---|
| Server | Fastify + Socket.IO |
| Persistence | SQLite via better-sqlite3 (WAL) |
| Frontend | React + Vite + Tailwind CSS + Zustand |
| Realtime | WebSocket (polling-first, upgrades to WS) over Socket.IO rooms |
| Tunnel | Cloudflare Quick Tunnel (cloudflared) |
| Language | TypeScript (strict), ESM |
See docs/ARCHITECTURE.md for the data model, the event protocol, and the discussion state machine — and docs/SPEC.md for the full design spec.
lean-coffee/
├── shared/protocol.ts # Single source of truth: event names, payloads, domain types
├── server/
│ ├── index.ts # Fastify + Socket.IO bootstrap, LAN/tunnel wiring
│ ├── engine.ts # Pure, testable state machine + timer math
│ ├── state.ts # SessionManager: rooms, snapshots, timer scheduling
│ ├── repo.ts # SQLite repository (WAL)
│ ├── handlers.ts # Socket intent handlers + host-token authorization
│ ├── http.ts # REST routes (create session, healthz, QR)
│ └── tunnel.ts # Cloudflare quick-tunnel spawner
├── web/src/
│ ├── views/ # Landing, HostConsole, Participant, JoinGate
│ ├── components/ # CircularTimer, JoinCard, TallyBars, VoiceToggle, …
│ ├── lib/ # socket client, speech, time, storage
│ └── store.ts # Zustand store fed by Socket.IO events
└── tests/ # Vitest unit (engine) + integration (live server) suites
npm run dev # Vite dev server + tsx watch (HMR), proxying API/WS to the server
npm run typecheck # tsc --noEmit (strict)
npm test # vitest run
npm run build # production build to dist/When iterating with
npm start, remember it builds the SPA and serves it fromdist/; after editing client code, rebuild (or usenpm run devfor HMR).
npm test runs 37 tests across two suites:
- Unit (
tests/engine.test.ts) — the pure engine: voting rules, top-topic selection & tie-breaks, timer math (pause/resume, ±time, extension), and the discussion state machine. - Integration (
tests/integration.test.ts) — boots a real Fastify + Socket.IO server on an ephemeral port and drives the full flow with Socket.IO clients: join → snapshot, topic add/vote broadcasts, one-vote-per-topic enforcement, host-only authorization, start → top-topic selection, continue poll & tally, extend/next, and timer-expiry auto-transition.
- Per-session vote budget UI (server enforcement already exists)
- Drag-reorder queue, park topics, "discuss now" override polish
- Session summary export (topics, votes, time spent) as Markdown
- Auto-advance suggestion when "move on" passes a threshold
- Emoji reactions and per-topic notes / action items
- Optional named Cloudflare tunnel for a stable recurring URL
Contributions are welcome! Please read CONTRIBUTING.md and our
Code of Conduct. Good first steps: pick a Roadmap item, open an issue to
discuss, and send a focused PR with npm run typecheck && npm test passing.
Found a vulnerability? Please follow the disclosure process in SECURITY.md rather than opening a public issue.
MIT © Joe Hunt