Updated 3 September 2026 to cover the pre-join sound check, the in-call Sound panel, named guest links, calendar replies, recorded transcripts with named speakers, and staying in the call.
Committee meetings are where a walking group actually runs itself: the walks programme gets agreed, the accounts get reviewed, the AGM gets planned. Until now, holding one online meant somebody's personal Zoom or Teams account, a join link pasted into an email chain, and minutes typed up afterwards from memory. Issue #357 set out to fold the whole lifecycle into NGX-Ramblers itself: plan the meeting on the site's calendar, send a proper calendar invite, join in the browser with no account, and come away with notes.
The design goals were firm from the start:
Jitsi Meet — open source, browser-native WebRTC, embeddable via its iframe External API — fits all four. The interesting work was everything around it: where calls run, who gets in, how invites travel, and how one container serves every site.
NGX resolves the Jitsi host through a single configuration value, the same pattern the integration worker uses for its own URL. The resolution order is environment variable, then the global Video Meetings config, then a public default (video-meetings-config.ts):
| Mode | What is set | Where the call runs |
|---|---|---|
| Default | nothing | Jitsi's public meet.jit.si page |
| Local development | JITSI_HOST_URL=https://localhost:8443 |
a Docker stack on the developer's machine |
| Self-hosted | host URL in Global Settings → Video Meetings | the estate's own ngx-ramblers-jitsi app on Fly |
The public default exists so the feature works with zero setup, but it comes with a catch: meet.jit.si no longer allows production embedding — an iframe call is treated as a demo and ended after five minutes. So when the resolved host is a public Jitsi domain, NGX opens the room on Jitsi's own page instead (unlimited, but their site and their Google/GitHub sign-in to start a room, and the UI warns about exactly that). When the host is ours, NGX embeds the call in the site via the iframe External API and the experience becomes seamless: member identity, no third-party page, no time limit.
Two derived flags drive everything downstream: publicHost (is the resolved host a public Jitsi domain?) and jwtRequired (JWT credentials are configured and the host is ours). publicHost:false, jwtRequired:true is the self-hosted sweet spot — every request to join a room must carry a token the site issued.
Every hosted group site in the estate is its own Fly app with its own database. Video meetings deliberately break that one-per-group pattern: there is a single Jitsi app for the whole estate, and every site points at it. Meetings are occasional and short — one committee call per group per month or two — so one modest machine covers everyone, and there is one thing to deploy and operate rather than one per group.
The split between what is global and what is per-site is strict. The host URL, JWT app id, room prefix and behaviour toggles live once in the global Video Meetings config, read by every site. The only per-site values are cosmetic: the brand name that appears on invites and the guest instructions text.
flowchart TB
subgraph estate["The NGX-Ramblers estate — one Fly app per group site"]
S1@{ icon: "ngx:ramblers", label: "Group site", pos: "b", h: 48 }
S2@{ icon: "ngx:ramblers", label: "Group site", pos: "b", h: 48 }
S3@{ icon: "ngx:ramblers", label: "…every other site", pos: "b", h: 48 }
end
CONFIG@{ icon: "ngx:mongodb", label: "Global Video Meetings config<br/>host · JWT app id · toggles", pos: "b", h: 48 }
subgraph jitsi["ngx-ramblers-jitsi — one shared Fly app"]
JIT@{ icon: "logos:docker-icon", label: "Combined Jitsi container", pos: "b", h: 48 }
end
MEMBER@{ icon: "ngx:user", label: "Member<br/>site login → JWT", pos: "b", h: 48 }
GUEST@{ icon: "ngx:user", label: "Guest<br/>emailed link → JWT", pos: "b", h: 48 }
CONFIG --> S1
CONFIG --> S2
CONFIG --> S3
S1 -->|"embeds call, issues tokens"| jitsi
S2 --> jitsi
S3 --> jitsi
MEMBER --> S1
GUEST --> S1
style estate fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
style jitsi fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:12,ry:12,color:#404143
The self-hosted server runs JWT-only (AUTH_TYPE=jwt, guests off at the server level). Jitsi itself has no user database and no sign-in page; the only way into a room is a token signed with the shared secret, and the only issuer of those tokens is the NGX server. That makes the site's existing membership system the identity provider for the video server, with no directory to sync.
Two token flavours come out of video-meetings-controllers.ts:
The guest join link that goes out by email is /video-meetings/guest/<room> on the group's own site. When the invite is sent from the planner, the link target is rewritten per recipient with their name and email as Brevo merge fields, so each invitee arrives already known: the site pre-fills their name, asks the server for a guest token in that identity, and the token carries an email-keyed occupant id so the same person joining twice is recognised and the stale seat is dropped. A plain link (copied from the room, say) works too — the visitor is asked what name to show and the browser remembers it. A logged-in member who opens a guest link is sent to the member room and keeps their own identity; the name, email and token parameters are stripped on the way.
Either way there is no app to install and no account to create: check your camera and microphone on the pre-join screen, click Join, and you are in.
The first committee calls taught a lesson that every video product learns eventually: people could see each other but not always hear each other, and the person with the problem had no way to tell that their computer had picked the wrong microphone. The original design skipped Jitsi's pre-join page in favour of dropping people straight into the call, which removed the one screen where a level meter and a device picker live.
So joining is now a two-step affair on purpose:
getAvailableDevices, setAudioInputDevice and friends), with a live level bar. The bar comes from the site itself, not Jitsi: a small Web Audio analyser on the chosen microphone, run in the parent page (mic-level-meter.ts).A related trick handles the other classic failure, two devices in the same room feeding back. Each participant's page emits a short, inaudible 18.5 kHz beacon and listens for anyone else's. Hearing another device's beacon through your own microphone means you are in the same room, and the site offers to silence this device rather than let the echo build.
Leaving a meeting by accident used to be a single stray click: any link on the site destroyed the room component and hung up. Three things now protect a live call:
canDeactivate guard on both room routes, so navigating anywhere else on the site while in a call opens the same "Leave this meeting?" dialogue as the Leave button, with Stay as the safe choicebeforeunload handler while in a call, so reload, back and closing the tab get the browser's own "Leave site?" promptFully automatic rejoin is deliberately not attempted: browsers need a gesture before they hand over the microphone and camera again, so a button is the honest option.
There is no meetings collection. A planned meeting is a committee file — the same document type that already holds a group's agendas and minutes — with the meeting's details on it: event date, title, agenda text, room name, format (in person, online or hybrid), venue, invitees, an optional invite note, the invitees' replies, and whether list sends go to committee role addresses. One document, so there is nothing to keep in sync.
Everything else derives from that document:
.ics calendar attachment is generated from it on demand, keyed by the document's idFile types carry their own meeting role (agenda or minutes), so meeting types are derived from the file types a group has already configured rather than a parallel taxonomy.
Planning a meeting composes an email to the chosen invitees — individual guests and any of the group's mailing lists — through the same Brevo transactional pipeline as every other site email. What makes it a first-class invite is the attachment: the server generates an iCalendar document at /api/calendar/meeting/<id>.ics with METHOD:REQUEST, the meeting's date and duration, the join link or venue in the description, and the committee secretary as organiser. Mail clients recognise that shape and render the real thing — Accept / Tentative / Decline buttons, a calendar preview, and one-click add-to-calendar (calendar-controllers.ts).
The planner can add a short note that goes into the email body so the title can stay short, and when every member of the chosen mailing list holds a committee role it offers to send to the committee role addresses instead of personal ones — the same rule the email composer applies.
Replies come back the same way the invite went out. When an invitee clicks Accept, Tentative or Decline, their mail client sends an iCalendar REPLY to the organiser, which is the group's own inbox. The inbox import recognises that shape, matches it to the meeting document by id, and records the RSVP against the invitee (apply-meeting-calendar-reply.ts). The planner shows who has replied and how, with no polling of anyone's calendar.
One wrinkle worth recording: Brevo fetches attachments by URL, which fails for a developer's localhost and for anything it cannot reach. The attachment layer detects that case, generates the .ics server-side, stores it in the site's S3 email-attachments folder and hands Brevo the public URL instead — so the same plan-and-invite flow works identically in development and production.
flowchart LR
PLANNER@{ icon: "ngx:user", label: "Committee member<br/>plans on calendar", pos: "b", h: 48 }
NGX@{ icon: "ngx:ramblers", label: "NGX site", pos: "b", h: 48 }
DB@{ icon: "ngx:mongodb", label: "Committee file<br/>date · agenda · room · format", pos: "b", h: 48 }
ICS@{ icon: "ngx:file", label: ".ics invite<br/>METHOD:REQUEST", pos: "b", h: 48 }
BREVO@{ icon: "ngx:brevo", label: "Brevo<br/>transactional email", pos: "b", h: 48 }
INVITEE@{ icon: "ngx:user", label: "Invitee<br/>RSVP + add to calendar", pos: "b", h: 48 }
JITSI@{ icon: "logos:docker-icon", label: "Self-hosted Jitsi", pos: "b", h: 48 }
PLANNER --> NGX
NGX --> DB
DB --> ICS
ICS --> BREVO
BREVO --> INVITEE
INVITEE -->|"named guest link + JWT"| JITSI
INVITEE -->|"calendar REPLY via the group inbox"| NGX
The standard Jitsi deployment is four cooperating containers (prosody for XMPP signalling, jicofo for conference focus, jvb for the video bridge, and the web front end) orchestrated by Docker Compose. Fly's model is one container per machine, and running four machines for a service that is idle most of the month would be wasteful anyway. So the estate image is a combined container: it bases on the official jitsi/prosody image and grafts the web (nginx), jicofo and jvb services onto it, letting the standard s6 /init supervise all four processes in one machine, driven entirely by environment variables (jitsi/Dockerfile).
Networking is the part Jitsi is famous for getting wrong on new infrastructure, so it is worth stating what works: Fly's edge terminates TLS and forwards to nginx on port 80; media flows over UDP 10000 to the video bridge on a dedicated IPv4, with JVB_ADVERTISE_IPS telling the bridge what address to put in its candidates; and if a restrictive network blocks UDP, Jitsi falls back to TCP 443, which is fine for small infrequent calls.
Deployment follows the same config-driven, deploy-from-admin pattern as the integration worker. deploy-jitsi.ts reads the Video Meetings config from the database, ensures the Fly app and its dedicated IPv4 exist, injects the secrets and host-specific values (PUBLIC_URL, JVB_ADVERTISE_IPS, the JWT secret, the jicofo and jvb component passwords), then validates and deploys with flyctl and scales the memory. The staging pipeline runs it automatically — but only when image-affecting files have changed, detected by diffing against a jitsi-deployed ref, so an ordinary site push never rebuilds the video server.
flowchart TB
ADMIN@{ icon: "ngx:user", label: "Admin<br/>Global Settings → Video Meetings", pos: "b", h: 48 }
GHA@{ icon: "logos:github-actions", label: "Deploy workflow<br/>runs only on Jitsi changes", pos: "b", h: 48 }
DEPLOY@{ icon: "logos:nodejs-icon", label: "deploy-jitsi.ts<br/>config + secrets from DB", pos: "b", h: 48 }
subgraph app["ngx-ramblers-jitsi — one Fly machine"]
NGINX["nginx web<br/>Fly edge TLS → :80"]
PROSODY["prosody<br/>XMPP signalling · JWT auth"]
JICOFO["jicofo<br/>conference focus"]
JVB["jvb video bridge<br/>UDP 10000 · TCP 443 fallback"]
end
ADMIN --> GHA
GHA --> DEPLOY
DEPLOY -->|"flyctl deploy<br/>dedicated IPv4"| app
NGINX --- PROSODY
PROSODY --- JICOFO
JICOFO --- JVB
style app fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
Nothing is recorded by the video server. Recording is a choice one member makes in the room with the Record button, and it runs in that member's browser: the microphone is captured in twenty-second chunks with echo cancellation off, so it hears everyone in the call through the speakers as well as the person at the keyboard. Each chunk is posted to the site and transcribed by a Gemini model through the estate's AI configuration (meeting-audio-transcription.ts). The prompt names the person whose microphone it is and everyone else in the room, and asks for one line per utterance labelled with the speaker, so the transcript stored in the meetingTranscript collection carries each line under the right name rather than putting every word in the recorder's mouth. Unknown voices keep the label the model gave them, unlabelled lines fall back to the recorder, adjacent repeats are dropped, and a words-per-second check throws away chunks where the model has plainly invented more speech than the audio could hold. Only one person should record: with echo cancellation off every microphone hears every voice, so two recorders would double every line.
Every meeting room also has a shared notes panel. Notes persist to a meetingNotes collection tagged with their author, so they survive a reload and are there when the minutes are written. On top of that sits an AI minute-writer: given the transcript, the chat, and any hand-written notes, it drafts structured minutes attributed to the named speakers — and the hand-written notes are folded in rather than overwritten. The AI output is saved as a single note authored "AI notes"; writing again replaces that note instead of stacking duplicates. A minutes editor then lets the secretary tidy the draft and publish it as a committee document, so it sits next to the agenda on the committee documents page, and the Meetings page lists any recording that still needs its minutes written.
The generation itself runs on the estate's integration worker when one is configured, keeping model calls off the site servers; without a worker it falls back to in-process generation. The worker suspends when idle — zero running machines between jobs — which keeps the AI capability effectively free until a meeting actually uses it.
A committee member plans a meeting on the calendar they already use, invitees get a real calendar invite from an address they recognise and their replies flow back into the meeting record, everyone joins in a browser with no account after a quick sound check, the committee moderates by virtue of being the committee, and a transcript with named speakers, notes and minutes come out the other end — and the whole estate's video traffic runs through one small container on hardware we control, with nothing recorded and no third party in the middle. The public-Jitsi default means a brand-new group has working video meetings before any of the self-hosting is set up; the self-hosted mode is where the feature is at its best.
The user-facing walkthrough, with screenshots of planning, booking and cancelling, is in the release note.