> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oximail.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# Meet

> Real-time calls over JMAP: a MeetSession anchored on a chat room, the heartbeat lease that defines who is actually in the call, and the transient MeetSignal relay that carries a WebRTC handshake without ever storing it.

Meet is the call surface, requested with `urn:oximail:params:jmap:meet`. The division of labour is the thing to understand first: OxiMail is the authority on **who may join and who is currently in** a call, and it **relays** the WebRTC handshake between participants. The media itself never touches the server.

## The capability object

Read the capability rather than hardcoding any of it — this is where the shipped limits are announced:

```json theme={null}
"urn:oximail:params:jmap:meet": {
  "maxParticipantsP2P": 3,
  "maxParticipantsSFU": null,
  "sfuAvailable": false,
  "recordingAvailable": false,
  "stunServers": [],
  "turnServers": null
}
```

`stunServers: []` and `turnServers: null` are honest rather than empty by omission: the server ships no ICE servers of its own. Supply your own, and expect peer-to-peer to fail between two symmetric NATs without a TURN relay.

## Objects and methods

| Method                                                 | What it does                                                                                                                         |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `MeetSession/create`                                   | Opens a session on a chat room. `chatRoomId` is required.                                                                            |
| `MeetSession/get`                                      | Standard read. With `ids: null` it returns the **active** sessions of every room you are a member of — see the discovery loop below. |
| `MeetSession/join`                                     | Joins as `(accountId, deviceId)` and takes a liveness lease.                                                                         |
| `MeetSession/refresh`                                  | Renews the lease and answers `expiresIn`.                                                                                            |
| `MeetSession/leave`                                    | Leaves explicitly.                                                                                                                   |
| `MeetSession/end`                                      | Ends the session for everyone.                                                                                                       |
| `MeetSignal/offer`, `/answer`, `/candidate`, `/failed` | Relays one signaling frame to one participant's device.                                                                              |

A `MeetSession` carries `id`, `accountId`, `chatRoomId`, an optional `calendarEventId`, `mode`, `config`, `participants`, `created`, and the optional `started` and `ended`. `participants` is **server-set** — it is loaded from the store and never taken from a client. `started` is stamped the moment the *second* participant joins, which is when a session becomes a call rather than an invitation.

A participant is flat on the wire — `accountId`, `deviceId`, `displayName` — with a `role` of `moderator` or `participant` and a `status` that phase 1 only ever sets to `active` or `left`.

## A session is anchored on a room

`chatRoomId` is mandatory, and it is what authorization keys on: you may join a session if you are a member of its room. There is no separate invite list to keep in sync with the room.

Creation fans a `StateChanged` for the `MeetSession` collection out to **every member of the room**, which is how a callee learns that a call exists without a dedicated notification type. That frame carries a state, not an id — so the second half of the loop is `MeetSession/get` with `ids: null` (RFC 8620 §5.1: every record the caller can see), which answers with the active sessions of the caller's rooms. Those two together are the incoming-call discovery loop; a client needs nothing else to ring.

## Membership is a lease, not a flag

Joining takes a lease keyed by `(account, device)`. It expires **30 seconds** after the last `join` or `refresh`, and `MeetSession/refresh` answers that window as `expiresIn` so a client can poll comfortably inside it rather than guessing. Sending a signal also counts as proof of life and touches the sender's lease.

When a lease expires, a sweeper flips that participant to `left` in the store and fans the change out; when nobody is active any more, the session ends by itself. This is why a browser tab that disappears does not leave a phantom in the call.

Leases live in memory and are never persisted. A server restart therefore drops all of them, and liveness is re-derived from whatever fresh `join`/`refresh` calls re-establish — the same semantics chat presence has, where everyone starts offline after a restart.

## Signaling is relayed, never stored

The four `MeetSignal/*` methods take `sessionId`, `toAccountId`, `toDeviceId` and `fromDeviceId`, plus the signal payload — `sdp`, `candidate`, `reason` — which is forwarded verbatim and never read.

* **Nothing is persisted, and nothing is logged.** There is no signaling mailbox, no queue and no retry: the relay is transitory by contract. A refusal logs the session and the signal kind, never the content.
* **`delivered` is an ack about connections, not about people.** `true` means at least one open WebSocket of the recipient received the frame. `false` means they have no open connection right now — not an error, and not queued for later. Handle it in your UX ("appears offline"), do not treat it as a failure to retry.
* **The server owns the frame's identity fields.** `type`, `signal` and `fromAccountId` are stamped *after* the caller's payload is spread into the frame, so a caller cannot overwrite them by putting those keys in their own payload.
* **Both ends must be active participants.** The sender is checked by `(accountId, fromDeviceId)` and the recipient by `toAccountId`/`toDeviceId`; anything else is `forbidden`, and any failure to resolve participants relays to nobody rather than to everybody.
* **Rate limited per `(tenant, account, session)`** at 120 signals per minute by default — sized for trickle ICE, roughly two per second. Over budget answers the RFC 8620 §3.6.1 limit error.

## What phase 1 does not do

The restrictions below are deliberate scope, not defects, and each one is observable from the capability object or from a named error rather than by trial:

* **Peer-to-peer only.** `mode` is fixed to `"p2p"`, `sfuAvailable` is `false`, and a join past three active participants answers `tooManyEntries`. There is no group mode to fall back to.
* **No recording, no waiting room.** `recordingAllowed` and `waitingRoom` exist in `MeetConfig`'s shape but are refused with `invalidProperties`.
* **No guests.** A participant's identity is member *or* guest at the schema level, and no phase-1 path creates a guest.
* **No `MeetParticipant/*` methods** — no admit, reject, kick or mute. Participants are read through the session.
* **One moderator, and it is the creator.** Moderation is not transferable.

That last point has a consequence worth stating: because a session cannot outlive its moderator in any administrable form, erasing the creator's account deletes the session as a whole rather than leaving an unadministrable one behind. Meet rows are part of what an account erasure removes.

The chat surface these sessions anchor on is documented on the [chat page](./jmap-chat); the session and push mechanics they rely on are on [JMAP Core](./jmap-core).
