# Meet console: rooms and joining

> How meetings exist, what a join token grants, how to moderate a live room, and how to point an organization at a conferencing engine and its signing key.

Conferencing in TeleQuick is multi-party audio and video carried over
MoQT. The console exposes four screens over it: an **Overview** that answers
"is this set up and does the engine answer", a **Rooms** moderation surface, a
**Join** screen that opens a real media session, and **Engine & keys** for the
per-organization engine URL and signing secret.

Everything the console shows about rooms and participants is read live from the
conferencing engine (`mod_meet`). There is no sampling and no rollup, so an
empty room list means the engine reported no rooms.

## What a meeting is: a name plus a token

`mod_meet` has no create-room API. A room is created on **first join** and
reaped when the **last participant leaves**. A meeting is therefore a room
*name* plus a token that authorises you to join that name.

Consequences that surprise people:

- There is no "create room" action in the console. **New meeting** on the
  Rooms screen mints a name and navigates you into it; the room appears in the
  list once someone is actually in it.
- A room is live engine state, not a stored record. Nothing persists a room
  between the last leave and the next join.
- **Close** on a room does not delete a record — it disconnects everyone
  currently in it. The same name reappears in the list if somebody joins again.

Room names come from the `roomName` schema the control-plane API enforces, and
the name becomes an element of the MoQT namespace. The console's generator uses
unreserved characters only, and draws them randomly rather than sequentially so
one room name does not reveal another.

## Joining: token minting and per-join identity

The Join screen calls `meet.join` with the org and the room name. The
control-plane API mints the token and returns it together with the join URL.
The client never supplies its own identity — if it could, anyone could join as
anyone.

`meet.join` accepts:

| Input | Effect |
| ----- | ------ |
| `room` | The room name to join. Created on first join if it does not exist. |
| `displayName` | Optional name carried on the token, shown instead of the raw identity. |
| `sources` | Optional list of sources this token may publish. |
| `subscribeOnly` | When true, the token is minted with `canPublish` false. |

It returns `token`, `expiresAt`, `url`, the derived `identity`, and
`existingSessions`.

**Identity is derived per join, not per user.** The server builds it as the
authenticated user id plus a short random nonce, joined with `~`
(`<user-id>~ab12cd`). The prefix keeps a session attributable to an account;
the nonce makes each join its own participant.

This matters because the bare user id was not safe. With it, one account joined
from a laptop and a phone claimed the *same* MoQT namespace and published
competing tracks under the same names; the relay's namespace home flipped
between shards and viewers saw the feed alternate between two fighting
publishers. Per-join identity gives each device its own participant, the model
you already expect from consumer conferencing.

The join token is minted with `admin: true` when the caller is an org admin
(admin grant, or the `owner`/`admin` org role). That is what makes the in-call
moderation controls work without a second round trip. Everyone else joins as a
plain participant.

## Multiple devices on one account and "switch here"

Because identity is per join, the same account can legitimately be in a room
more than once. Before minting, `meet.join` lists the room's participants and
returns the ones whose identity carries the caller's own `<user-id>~` prefix as
`existingSessions`. That is the signal behind a "you're already in this
meeting — switch here?" prompt.

The lookup is best-effort: if the engine does not answer the list call, the
join still proceeds with an empty `existingSessions`.

A companion procedure lets a caller kick **their own** other sessions out of a
room. It is viewer-gated rather than admin-gated because it is hard-scoped
server-side to identities carrying the caller's own `<user-id>~` prefix, so it
can never remove anybody else.

## Publish scope: sources and subscribe-only joins

Two token fields shape what a join can send:

- `sources` — the set of sources the token permits publishing. Omit it to take
  the engine's default for the token.
- `subscribeOnly` — sets `canPublish: false` on the minted token, for an
  observer who should receive media but never send it.

Both are decided at mint time, on the server. A participant already in a room
can still have their publish permission changed afterwards from the Rooms
screen.

## Diagnosing no video: arrived, decoded, attached

When a participant reports "I can hear them but I can't see them", the page
itself tells you nothing: an empty tile looks identical whether nothing was
delivered, frames arrived and were never decoded, or they decoded and were
never attached to an element. Working audio already proves the session, the
namespace and the transport, so guessing between those three is what wastes the
time.

The Join screen installs a diagnostics handle for exactly this. From the
browser console, in a joined session:

```js
window.__meet.video()
```

It returns one entry per remote **video** track, keyed
`<identity>/<source>`:

| Field | Meaning |
| ----- | ------- |
| `subscribed` | Whether the publication is subscribed at all. |
| `receivedFrames` | Frame objects that actually arrived, counted before WebCodecs sees them. |
| `receivedKeyframes` | Arrived keyframes. A decoder cannot start without one. |
| `receivedBytes` | Bytes delivered for this track. |
| `decoderStarted` | Whether a decoder was ever started for the track. |
| `attachedElements` | How many media elements the track is attached to. |

How to read it:

- **All counters at zero** — nothing is arriving. The problem is upstream of
  this participant: the publisher, the subscription, or namespace routing.
- **Counters climbing, `decoderStarted` false** — media is arriving but no
  decoder ran. Look at keyframes: without `receivedKeyframes` there is nothing
  to start from.
- **`decoderStarted` true, `attachedElements` zero** — the track decoded and
  was never attached to an element. The failure is in the UI layer, not the
  transport.

`window.__meet.room` is the same `Room` object the UI is driving, if you need
to inspect it directly.

## When the engine does not answer

The Join screen has two timers, both cleared the moment the room connects or
the join fails on its own:

- At **12 seconds** it marks the join as slow — "Still connecting to `<room>`…"
  plus a pointer at **Engine & keys**.
- At **30 seconds** it gives up with a real error: the conferencing engine did
  not answer, check the engine URL.

Until either fires you get a progress line and a **Cancel** button back to
Rooms. Cancelling, or navigating away, runs the screen's cleanup, which
disconnects the half-open session — without that, the camera light stays on and
you stay in the room for everyone else.

On the Rooms screen, **New meeting** is disabled while the engine is not
reachable. Joining an unreachable engine fails at the transport with a far less
legible message than a disabled button.

## What's in the console

| Screen | What it answers |
| ------ | --------------- |
| Overview | Is conferencing configured, does the engine answer, what is running now. |
| Rooms | Who is in each room, what they publish, and the four operator actions. |
| Join | Opens a real media session in a room. |
| Engine & keys | The per-org engine URL, token issuer, and signing secret. |

## Read the overview and check the engine

Overview polls `meet.status` and, only once the engine is known to answer,
`meet.rooms.list`. Gating the second query matters: polling rooms against an
unreachable engine costs a control-plane timeout and reports the same failure
twice.

`meet.status` returns four fields and three meaningful states:

| State | `configured` | `reachable` | What it means |
| ----- | ------------ | ----------- | ------------- |
| Not set up | false | false | No engine or signing key exists for this org. `detail` says so. |
| Unreachable | true | true/false | Configuration exists but the engine's control plane did not respond. `detail` carries the error. |
| Reachable | true | true | The health probe answered. `rooms` is the engine's count, `detail` is `shard <n>`. |

"Not set up" is a first-class state, not an error — an org that has just been
granted the modality lands there, and the only useful next step is Engine &
keys.

The **Engine** tile's subtitle shows which shard answered. That number changes
between reloads because health probes are load-balanced across the engine's
reactor shards; a different shard answering is normal, not a fault.

## Registry count vs engine shard count

Overview shows two counts and they can legitimately disagree:

- **Active rooms** is the length of the room list from the engine registry.
- **Reported by engine** is `rooms` from the health probe — every room the
  *answering shard* still holds, including one a join opened and never finished
  tearing down.

When they differ, the tile labels the engine number as a shard count that
includes rooms still tearing down. When they match, it reads as rooms held on
the answering shard. Neither number is stale data from a job; both are live
reads of different things.

## Visible vs hidden participants

Each room row reports `numParticipants` and `numVisible`. A hidden participant
— a recorder, an observer — is in the room but is not rendered to peers. The
console shows both numbers and tags the difference (`+2 hidden`) so the gap
reads as a deliberate state rather than a mismatch.

`hidden` is one of the five permissions you can toggle per participant.

## Moderate a live room

Expanding a room on the Rooms screen queries `meet.participants.list` and
polls it while open. Each participant row gives you:

- **Tracks** — one button per track. Clicking it calls
  `meet.participants.mute` with the track sid and the inverted muted state.
- **Permissions** — five toggles, each calling `meet.participants.update`.
- **Remove** — `meet.participants.remove`, after a confirmation.

The room header additionally offers **Close** (`meet.rooms.remove`) and a copy
button for the room name.

A rejected mutation is surfaced on the row it came from rather than silently
leaving a toggle looking like it worked.

## The participant permission model

Five flags, gating five different things:

| Permission | Gates |
| ---------- | ----- |
| `canPublish` | Whether the participant may publish media at all. |
| `canSubscribe` | Whether the participant may receive other participants' tracks. |
| `canPublishData` | Whether the participant may publish data messages. |
| `roomAdmin` | Whether the participant has moderation authority inside the room. |
| `hidden` | Whether the participant is in the room without being rendered to peers. |

`meet.participants.update` takes the **whole** permission object, not a patch —
its schema requires every field, and sending a partial would silently clear the
ones you omitted. The console always spreads the participant's current
permissions and overrides the single flag being toggled.

## Tracks, sources and mute

Each track in the list carries a `sid`, a `kind` (`audio`, `video`, or data)
and a `source`. Mute is per track: you pass the `trackSid` and the desired
`muted` state, so muting a camera does not touch a microphone.

An **empty** track list is a real state, not an error. The engine registers a
track when it first carries media, so a participant who just joined, or who has
both devices off, has no track data yet. The console says "No track data"
rather than "not publishing" — absence of a frame is not evidence about
somebody's intent.

Likewise, a room can be listed with **no participants**: it is still open
because the engine has not reaped it yet.

## Removing a participant vs closing a room

| Action | Scope | Aftermath |
| ------ | ----- | --------- |
| Remove participant | One identity. | Disconnected immediately. They can rejoin with a valid token. |
| Close room | Every participant in the room. | Everyone is disconnected. The room reappears if someone joins the name again. |

Neither is a permanent block. Both are confirmed before they run, because both
are immediately visible to everyone in the call. Revoking access durably is a
matter of the token — see rotation below.

## Point an organization at a conferencing engine

Engine & keys holds the per-org conferencing config, read with
`meet.config.get` and written with `meet.config.set`. This replaced what used
to be process environment, so a single deployment can serve many
organizations, each with its own engine and its own rotatable secret.

Three things live on this screen, with very different blast radii:

| Item | Blast radius |
| ---- | ------------ |
| Engine URL | Where this org's rooms are hosted. Changing it points **new** joins elsewhere; sessions already up are unaffected. |
| Token issuer (`apiKey`) | The `iss` claim on join tokens. Unique across orgs. |
| Signing secret | Never rendered. Anyone holding it can mint themselves room-admin on every room in the org. |

The Engine URL field accepts `http(s)://` or `ws(s)://` and normalises it —
the same host serves the media plane over QUIC and the control plane.

Saving an engine URL for an org that has none generates an issuer and a signing
secret and pushes them to the engine. The confirmation banner is driven by
`hasSecret` on the mutation's own response, so it cannot claim conferencing is
enabled while the rest of the screen still reads "not configured".

## The issuer and the signing secret

The issuer is the `iss` claim the engine uses to select which secret to verify
a join token against. It is unique per organization, and it is safe to read and
copy — the console shows it in a read-only field with a copy button.

The signing secret is different. The control-plane API projects it away before
the response leaves the server; a browser only ever receives `hasSecret`, and
the console renders that as `set · not displayed` or `missing`. If `hasSecret`
is false, the engine URL is saved but conferencing is not finished being
enabled — no join can be authorised until a signing key exists.

## Rotate the secret without dropping participants

**Rotate** (`meet.config.rotateSecret`) keeps the issuer and replaces the
secret. Rotation is a separate, deliberate action rather than a field on the
form, because of what it does:

- Every join token minted under the old secret **stops verifying** once the
  engine re-reads its keys. Anyone mid-join has to request a new token.
- Participants **already connected are not disconnected**.

So rotation blocks pending joins and leaves live calls alone. That is the tool
for a leaked or suspect secret.

## Re-push keys when the engine has drifted

**Re-push keys** (`meet.config.resync`) sends this org's signing keys to the
engine again from the platform database. Use it when the config in the console
looks correct but the engine behaves as though it does not have the key — the
platform is the source of truth and this restates it, without minting anything
new.

Reach for re-push before rotation. Rotation invalidates every outstanding
token; re-push invalidates nothing.

Both rotation and removal take effect as soon as the engine re-reads its keys.

## Remove a configuration and re-enable later

**Remove** (`meet.config.remove`) deletes the engine URL and signing key for
the organization. Afterwards no new session can be authorised. The modality
entitlement is **not** affected — re-enabling is a matter of setting an engine
URL again, which generates a fresh issuer and secret.

The action is confirmed and marked destructive, and Overview will fall back to
its "not set up" state for the org.

## Related

- [Authentication](/concepts/authentication) — API keys, relay tokens, and namespace scope
- [Telemetry](/platform/telemetry) — metrics and traces emitted by the gateway
