Meet: video meetings and council meetings
Meet runs in the browser. By default media goes peer to peer between the
participants (a mesh), with the API relaying only signaling over /ws/meet
(Redis pub/sub across replicas) and ICE servers from MEET_ICE_SERVERS. A mesh
suits small meetings: every participant sends their video to every other, so
beyond roughly eight people bandwidth and CPU run out. With MEET_JITSI_DOMAIN
set, meetings run on a Jitsi server (an SFU) instead, which is the setup for
large meetings. GET /meet/jitsi-token?room=<slug> signs a token for that one room only,
after the same access check as the other meeting routes (organizers get moderator). See modules.md for the general module layout.
The host can record the meeting’s audio: the recording lands in the host’s Files (“Meeting recordings”), and with a transcription endpoint and an LLM configured, AI minutes and action items follow. The host can also spotlight, mute and remove participants. Live captions and guest invitations (people outside the organization) are also supported. Using Meet day to day is in the user guide.
Outside guests. A meeting link alone does not admit an outsider: a signed-out visitor is sent to the sign-in page. A guest needs an invitation to that meeting, and an admin has to allow guests first (Admin ▸ Organizations ▸ the organization ▸ Guests tab, see chat-advanced.md). An organizer or co-organizer (and an org admin) invites from Invite guest: in the room it is in the People panel, and on the meeting list it is the person-with-plus icon next to a meeting’s copy-link button, so it works before anybody has joined. They type the guest’s email address; the guest gets a link by email (the panel can also copy it, for when the email does not arrive), opens it, chooses a name and a password, and lands in the room. The panel lists every invitation: waiting, accepted (with the name the guest chose) or expired, with Send again and Remove. Removing a guest takes them out of the room at once. Asking again for an address that already has a live invitation to the meeting sends that invitation again instead of creating a second one.
A guest who already has a guest account (from an earlier invitation) enters that
account’s existing password on the accept page instead of choosing a new one; the
invitation never changes it (403 guest_account_exists, or guest_account_disabled
for an account an administrator switched off). A guest cannot create meetings.
In the room a guest is labelled Guest in the People panel and can never be made a
co-organizer. With the lobby set to GUESTS they wait to be admitted (a guest who was
removed while waiting is not let in). An AI agent and guests do not mix: an agent cannot
be invited into a meeting that has accepted guests, and guests are not admitted while an
agent is present; the panel says so. The same invitation is POST /api/v1/meet/meetings/:slug/guests ({"email": …}; see the
API reference).
NAT traversal (STUN and TURN)
Two browsers in a mesh have to find a path to each other. On the same network they
connect directly. Across networks, each first asks a STUN server for its public
address; when both sit behind strict NATs or a firewall that blocks UDP, only a TURN
server that relays the media connects them. Browsers get the list from
GET /api/v1/meet/config (iceServers), the native chat app too.
| Setting | What it gives | Who sees what |
|---|---|---|
MEET_ICE_SERVERS (default Google’s public STUN server) |
STUN only: a direct path when the NATs allow one | Google sees every participant’s IP address |
Our own TURN server (Helm turn.enabled, coturn) |
STUN and a relay: calls connect across any two networks that allow UDP or TCP to the TURN port | nobody outside your deployment; the media is DTLS-SRTP encrypted end to end, the relay only forwards it |
MEET_ICE_SERVERS: [] and no TURN |
direct paths only | nobody, but calls between two different NATs often fail |
With the bundled TURN server, set meet.iceServers: []: coturn answers STUN itself, so
Google drops out. The API adds one entry to iceServers per request, with a credential
minted for the signed-in user from a secret it shares with coturn (MEET_TURN_SECRET,
the TURN REST API scheme, lib/turn.ts): the username is <expiry>:<user id>, the
password an HMAC over it, and coturn refuses it after turn.ttlSeconds (12 h by
default). Nothing is stored, and a credential copied out of a browser stops working.
MEET_TURN_URLS lists the TURN URLs (the chart sets UDP and TCP on turn.host:port).
The relay refuses private, loopback, link-local and other special addresses as peers and has TCP relaying off, so a signed-in user cannot use it to reach the cluster (security.md). A guest’s credential ends with their access.
How many people the relay carries: a browser takes about 2 relays per peer connection (UDP and TCP; 4 on a
dual-stack host), counted per user, so one person in a mesh of N takes up to 4 x (N - 1) of turn.userQuota (64
by default) and the whole mesh up to 4 x N x (N - 1) of turn.totalQuota (200, one per relay port). That
covers a mesh of about 7 people who all need the relay; most pairs connect directly and use none. For larger
meetings use Jitsi. The room fetches its connection list when you join and renews it every hour; when the fetch
keeps failing it says so, because people on other networks then cannot reach you. It has no TLS listener: a network that blocks
both UDP and TCP to the TURN port cannot use it (TURN over TLS on port 443 needs its own
IP address, because the ingress owns 443). Setup and firewall ports are in
deployment.md. To check a
call, open chrome://webrtc-internals in the browser: the selected candidate pair says
relay when the media goes through the TURN server.
Meeting options, roles and the lobby
Organizers (the host and any co-organizers they appoint) control a meeting with
Teams-style options (PATCH /meet/meetings/{slug}/options, the gear button in the
room). lobby is OFF, GUESTS (external guests wait) or EVERYONE (everyone but
organizers waits); organizers admit or deny from the people panel or the banner.
Everyone who enters the room counts as admitted, so someone who reconnects is not sent back
to a lobby that was switched on in the meantime. A newly appointed co-organizer receives the
current waiting list ({type:'lobby-list'}; an empty one when demoted). allowMic,
allowCamera, allowChat, allowReactions, allowSharing and muteOnEntry bind
attendees, never organizers. Media is peer to peer, so the mic, camera and sharing
limits are honoured by the clients (like force-mute); the gateway enforces the lobby,
chat, reactions and the whiteboard itself. “Mute all” spares organizers. Webinar mode
is a preset that turns off mic, camera and sharing for attendees. The organizer can
appoint and remove co-organizers live and end the meeting for everyone. Nobody removes
or silences the organizer, and only the organizer acts on a co-organizer. Ending is
final: the meeting (and its breakout rooms) is marked inactive, so it leaves the
list, the gateway turns away anyone who returns (4004), and the whiteboard and
spotlight are cleared, the council speaking clock stops and its queue is emptied; the
organizer’s attendance report, chat history and speaking times stay available, also from
the Meet tab’s “Recently ended” list (GET /meet/meetings/ended: meetings you organized or
co-organized that ended in the last 90 days). The gateway pings every socket every 20
seconds and drops one that misses a pong, so someone whose connection died silently leaves
the room and the attendance report. Reactions
are a fixed set, rate limited to 8 per 5 seconds per person.
Calls from chat are private rooms
A 1:1 call from Chat creates its room with POST /meet/meetings {title, channelId}.
The caller must be a member of that conversation. The room is locked (private):
it is not in anybody’s meeting list, only its host and the conversation’s members can
look it up or join (4004 otherwise), and nobody waits in a lobby for the person who
rang them, even where the organization’s policy requires a lobby. Only the host can
ring people into a room (call-invite). The native Chat app passes channelId
for a 1:1 call too. A group or channel meeting (the native app starts one and posts its
link; the web app opens Meet) stays an ordinary open room, so people who join the
conversation later are not locked out. The native Mail app creates no call rooms: its
online-meeting toggle on an event and its Quick meeting are ordinary rooms, as the Meet
tab’s New meeting is.
Chat, attendance, polls and Q&A
The in-meeting chat is stored (MeetingChatMessage): late joiners receive the recent
history in joined and GET /meet/meetings/{slug}/chat returns it afterwards.
Organizers get the attendance report (GET .../attendance, ?format=csv) built from
the gateway’s MeetingSession rows: who, first join, last leave, number of joins and
minutes. Minutes are the union of a person’s sessions, so two devices at once do not count
twice; a graceful restart closes the open sessions; and the rows outlive a deleted meeting
(meetingId is set to null), so the organization’s usage figures keep them. Polls (single or multiple choice, optionally anonymous) and Q&A (optionally
anonymous questions, upvotes, answered) live under .../polls and .../questions;
GET .../engage returns both as the caller may see them: poll results only after
voting, once closed or to organizers, and an anonymous author is hidden even from
organizers. Changes push {type:'engage'} over the room socket.
Breakout rooms
Entry to a breakout room is by assignment (or being an organizer), so nobody waits in a lobby for it, whatever the main meeting’s or the organization’s lobby setting.
An organizer creates rooms (POST .../breakouts, by name with assigned people or a
count with shuffle), moves people between them while open
(PUT .../breakouts/{room}/members), then opens and closes them (/open, /close).
A room is a child Meeting (parentId), so chat, options and the lobby work in it;
children never appear in the meeting list, and only the assigned people and
organizers can join one. A guest invited to the main meeting reaches the room they are
assigned to (captions, polls, chat history, council) through that invitation. Opening pushes {type:'breakout', assignments} to the main
room, each client finds its own room by user id and offers “Join room”; closing sends
{type:'breakout-closed'} into every room and everyone returns to the main room.
Whiteboard
A shared whiteboard overlays the stage for everyone while open. Strokes are
{id, c, w, p} with coordinates as fractions of the board, streamed as short wb-live
chunks and stored on pen-up (Redis, 2000 strokes, for the life of the room) so a late
joiner gets the picture. Anyone undoes their own stroke, organizers undo any and clear
the board; drawing follows allowSharing. The board downloads as a PNG.
Faded camera background
The browser room and the native Chat app can fade everything behind you:
MediaPipe’s selfie segmenter runs on the device,
the person stays sharp and the rest is blurred and lifted toward white, and the result is
a canvas track that replaces the camera track. The wasm runtime is served from the npm
package (copied into the web build; the nginx CSP carries 'wasm-unsafe-eval'), the
250 kB model is committed. Native: apps/chat/README.md.
Org meeting policy and translated captions
Admin ▸ Meet policy (GET/PUT /admin/meet-policy, per organization) sets a lobby floor
organizers cannot go below (minLobby; enforced in the options route and at join, even on a
meeting whose own lobby is off), the lobby and mute-on-entry defaults for new meetings, and
switches for recording (allowRecording: the upload route and the gateway refuse, the room hides the
button) and live captions (allowCaptions). A participant can have the captions they see translated
into their language (POST .../translate, one line at a time through the org’s LLM; nothing is
stored, and without an LLM the original line is shown): /meet/config advertises
captionTranslation and recording.
Transcription (captions and minutes)
Live captions and AI minutes share one speech-to-text endpoint: any OpenAI-compatible
POST /audio/transcriptions (multipart, response_format=json). resolveTranscription(db, orgId)
(lib/llm/transcription.ts) picks it per organization, like the AI provider:
- The organization’s own (Admin ▸ Meet policy ▸ Transcription;
GET/PUT /admin/meet-transcription,POST .../testsends one second of audio). A preset (OpenRouter, Mistral, OpenAI, Scaleway) or a custom endpoint, a model (empty: the preset’s), an API key sealed like the AI provider’s, the spoken language, and Require EU residency. With no key of its own it borrows the AI provider’s key when that names the same provider and endpoint, so an organization already on OpenRouter only picks OpenRouter here. A key that no longer opens fails closed: no captions, no fallback. - The installation’s
TRANSCRIBE_BASE_URL,TRANSCRIBE_MODEL,TRANSCRIBE_API_KEYandTRANSCRIBE_LANGUAGE(Helm:config.transcriptionandsecret.transcribeApiKey), unless the organization requires EU residency.
With neither, both features are off: the CC button is hidden and a recording is filed without minutes.
- Recording. The organizer or a co-organizer can record and upload it; the
recording and its minutes go to the person who recorded, and whoever started a recording
may still stop and upload it after being demoted. Several organizers can record at once:
each recording keeps its own caption buffer (renewed by every caption line, up to 12 hours),
and the room shows “not recording” only after the last one stops. Everyone in the room,
late joiners included (
joined.recording), sees that a recording runs; the native Chat app shows it too. A failed upload is kept in the browser with Try again and Save to this device, and the tab asks before it closes while a recording runs. - Captions. Each participant’s browser sends its own microphone in 4-second segments
(
POST .../caption); the text is broadcast to the room. A caption is refused when the organization turned captions off or the attendee may not speak, and it is collapsed to one line. A segment that comes back as one of the model’s known silence hallucinations (“thanks for watching”, Whisper’s Dutch “Ondertiteld door de Amara.org gemeenschap”) is dropped. - Minutes. The recording itself is always transcribed, so the minutes cover everyone who spoke;
the caption lines go alongside it, quoted under their speaker (
Name: "words"), to say who said what. The minutes date and the recording’s file name use the recorder’s time zone (else the organization’s). Chrome records at about 128 kbps (an hour is ~58 MB) and hosted endpoints take 25 MB per upload, so a WebM recording goes up in 10-minute pieces (lib/webm.tssplits it at Cluster boundaries; a recording it cannot read, such as Safari’s mp4, goes whole). - Language. The organization’s spoken language (else
TRANSCRIBE_LANGUAGE; ISO-639-1, e.g.nl) is sent with every request. Four seconds is short to detect a language from, so set it when meetings are held in one language; leave it empty when they mix.
| Endpoint (preset) | Base URL | Default model | Notes |
|---|---|---|---|
| OpenRouter → Mistral Voxtral | https://openrouter.ai/api/v1 |
mistralai/voxtral-mini-transcribe |
$0.003/min; Dutch supported. OpenRouter is a US intermediary: turn on zero data retention for the account. |
| Mistral, direct | https://api.mistral.ai/v1 |
voxtral-mini-latest |
The same model without the intermediary (EU). Not yet tested from Goooy. |
| OpenAI | https://api.openai.com/v1 |
gpt-transcribe |
whisper-1 and the gpt-4o-*-transcribe models shut down on 2027-02-26. |
| Scaleway | its Generative APIs URL | whisper-large-v3 |
EU (FR). |
| Self-hosted | your server | as served | Any server with the OpenAI transcription API (vLLM, faster-whisper based). |
Audio leaves the cluster to whichever endpoint is set: under DATA_RESIDENCY=eu-strict its host must be
on the allow-list (data-sovereignty.md). An organization’s endpoint is checked
like the AI provider’s (no internal, loopback or metadata addresses, at save and at use; no redirects).
Choosing a model for Dutch. Public leaderboards do not score Dutch, so scripts/stt-bench.mjs
measures candidates on the FLEURS nl_nl test set (251 sentences, one recording each, ~40 min) the way
Meet calls them: 4-second caption segments, 10-minute minutes pieces and near-silent clips. It reports word
error rate, latency and cost per candidate, with and without the language hint:
OPENROUTER_API_KEY_FILE=~/.config/goooy/openrouter.key node scripts/stt-bench.mjs
node scripts/stt-bench.mjs --candidates mine.json --real ~/meetings # other endpoints, your own recordings
The run of 2026-10-05, through OpenRouter, with the language set to nl (word error rate; the caption delay is
the median and the 95th percentile of a 4-second segment; “near-silence” counts the 20 quiet clips that came back
with text):
| Model | Captions | Minutes | Caption delay | Near-silence | Per audio hour |
|---|---|---|---|---|---|
openai/gpt-transcribe |
11.8% | 5.3% | 0.70 s / 1.85 s | 0 of 20 | $0.27 |
mistralai/voxtral-mini-transcribe |
17.4% | 5.5% | 0.33 s / 0.67 s | 0 of 20 | $0.16 |
openai/whisper-large-v3 |
24.0% | 6.7% | 2.06 s / 5.78 s | 11 of 20 | $0.06 |
openai/whisper-1 |
20.9% | 11.1% | 0.92 s / 2.01 s | 20 of 20 | $0.36 |
Every model does far worse on captions than on minutes: a fixed 4-second cut splits words. The language hint
lowers the caption error of Voxtral (19.9% detected, 17.4% with nl) and GPT Transcribe (13.3%, 11.8%) and
raises Whisper large-v3’s (21.9%, 24.0%). Whisper-1 answered every quiet clip with text, such as “Ondertitels
ingediend door de Amara.org gemeenschap”. Voxtral through OpenRouter answered 429 at eight parallel requests,
far above what a meeting sends. FLEURS is read speech: meeting audio scores worse for all of them, which
--real measures with your own recordings.
AI agents in a meeting (voice)
An organizer or co-host can invite one of the organization’s AI agents into the room (People panel,
Invite AI agent): the agent joins as a participant with an AI badge, and people talk to it by
voice. Off by default: ElevenLabs must be configured on the deployment, Allow AI agents in calls
and meetings (allowAgents in the meeting policy above) must be on, and the agent must have voice
turned on. /meet/config advertises agents.voice; GET /meet/agents lists the agents an organizer
can pick from; POST|DELETE /meet/meetings/:slug/agents[/:agentId] invite and remove (organizer or
co-host; the person who invited it may also remove it).
In a meeting the default is push to talk: one person at a time holds the button (or Space) and
the room hears the answer; the organizer can switch to open (only the person who invited the
agent is heard, without pressing anything). An agent is never offered to a guest, is refused in a
meeting that has accepted guest invitations, and a guest arriving next to an agent is refused with
close code 4009: the agent answers from the organization’s data to everyone it can hear. Everyone in
the room sees a notice that an AI is on the call, and the roster and the tile say so. The agent is a
virtual peer in the room socket (agent: true): its voice arrives as agent-audio frames and the
people’s microphone goes out as agent-in; kick, cohost and mute never apply to it. The call
ends when the last person leaves, after the idle time, at the call length limit, or when it is
removed. Everything about the call (the floor, the ElevenLabs conversation, the limits, privacy and
cost) is in agents.md. The native
Goooy Chat app does the same (Invite AI agent in the call window, hold to talk, Stop). Limits of
v1: one agent per meeting, and the built-in mesh room only (not the Jitsi-mode page).
Council meetings
A gemeente’s council and committee meetings are hybrid now: part of the room in the chamber, part at home, the public watching. Council mode adds what such a meeting needs on top of the call, and works the same on the mesh and on Jitsi. The meeting’s host is the chair.
Agenda. The chair sets the agenda items (title, a description, links to the
stukken, typically in the raadsinformatiesysteem; only https:// links are
kept) and opens the item under discussion. Opening the next item ends the
current speaker’s turn and starts an empty speaker list.
Speaker list. Anyone in the meeting presses Ask to speak; the chair adds people in the chamber by name (the chamber is usually one participant: a room system with its own microphones). The chair gives the floor; whoever had it stops. The turn shows a running clock against the speaking time per turn, red once it is over; the speaker or the chair ends it. The chair sees the speaking time per person and downloads the turns as CSV (item, speaker, start, end, seconds) for the minutes. Nothing mutes anyone automatically: the chair still uses mute in the People panel.
Public page. The chair switches on a public page (/raad/<token>, no login):
the item under discussion with its stukken, who has the floor and for how long,
who is next, and the agenda. It polls every three seconds. Switching it off
revokes the link. The page shows no user ids, totals or links to the call.
Livestream. A gemeente streams its council meetings through a webcast provider already (often the one behind its raadsinformatiesysteem). The chair enters that stream’s address and the public page links to it. It is a link, not an embedded player: the SPA’s content security policy allows no third-party frames. Goooy does not stream video itself; a Jitsi server with Jibri can stream to an RTMP endpoint, which is set up on the Jitsi side.
Changes reach participants over the meet socket ({type: "council"}) in mesh
mode and by polling (every three seconds) next to a Jitsi meeting.
Not done: votes (hoofdelijke stemming) and their result on the public page; fractions and speaking time per fraction; interrupting (interrupties) as a separate list; video recording; a player on the public page.
API (JWT, anyone who may join the meeting; chair = the host):
| Method + path | Who | What |
|---|---|---|
GET /meet/meetings/{slug}/council |
participant | agenda, current item, speaking, queue; for the chair also the public token and totals |
PUT /meet/meetings/{slug}/agenda |
chair | replace the agenda (items: [{id?, title, description?, links?}]) |
PATCH /meet/meetings/{slug}/council |
chair | currentItemId, publicPage, livestreamUrl, speakingSeconds |
POST /meet/meetings/{slug}/speakers |
participant / chair | ask to speak; with name, the chair adds someone |
POST /meet/meetings/{slug}/speakers/{id}/floor |
chair | give the floor |
POST /meet/meetings/{slug}/speakers/{id}/end |
speaker / chair | end the turn |
DELETE /meet/meetings/{slug}/speakers/{id} |
self / chair | leave the list |
GET /meet/meetings/{slug}/speaking-times |
chair | CSV of all turns |
GET /meet/public/{token} |
public | the public page’s data |
Tests: apps/api/test/smoke-council.mjs (in run-all), apps/web/e2e/council.spec.ts
(the public page without login, and the chair’s panel in a room with fake media,
where a request to speak arrives over the socket).