# Meetings

People start an online meeting from their workspace, hold it as a video call, and find it in `raw`
afterwards: the recording, and a transcript that names who said what. CoDNA runs no calls itself. A
**meetings service** at `CODNA_MEET_URL` holds them, on whatever media server it likes, records each
person's audio on its own track, and hands the recording back once the call is over. CoDNA files it as
a recording and sends the tracks to the [transcription service](https://codna.me/docs/transcription.md) together, so the
transcript's speakers are the people in the call rather than "Speaker 1" and "Speaker 2". The
reasoning is [adr/0018](https://codna.me/docs/adr/0018-meetings-recorded-per-person.md).

This page is the contract between CoDNA and a meetings service. Like transcription's, it is small on
purpose, so that anyone can put their own media server behind it. The hosted service at codna.me is one
implementation of it, on LiveKit.

## Settings

- `CODNA_MEET_URL`: the service's base URL, such as `https://meet.example.com`. CoDNA appends the paths
  below to it. Unset, there is no Meeting button.
- `CODNA_MEET_TOKEN`: the bearer token CoDNA sends. One per instance: the service knows which instance
  is calling by its token.

## Version 1

Every path starts with `/v1`. Every request carries `Authorization: Bearer <token>`.

Within v1, either side may add **optional** fields, and both ignore fields they do not know. Anything
else is a `/v2`, and a service keeps answering `/v1` until the cores calling it have moved.

### Start a meeting

```
POST /v1/meetings
Content-Type: application/json
Idempotency-Key: 0192a4c6-6f1e-7c3a-9e55-3c1d2f0a9b11

{
  "title": "Weekly sync",
  "workspace": "Acme",
  "host": { "id": "0192a4c6-…", "name": "Nino Beridze" },
  "notice": "Recordings on this workspace are kept to improve speech recognition for Georgian.",
  "guests": true,
  "page_url": "https://kb.acme.example/meetings/0192a4c6-6f1e-7c3a-9e55-3c1d2f0a9b11?invite"
}
```

- `title`: what the person starting it called it.
- `workspace`: the workspace's name as people know it, shown in the call: `CODNA_ORG_NAME`, or the host
  CoDNA is served at when that is unset.
- `host`: CoDNA's id for the person starting it, and their name.
- `notice`: optional words everyone joining is shown, beside the service's own "this meeting is
  recorded". CoDNA sends the transcription service's notice while that service keeps recordings.
- `guests`: whether anyone with a link may join, typing their own name. CoDNA always asks for one.
- `page_url`: optional, the meeting's page in CoDNA, with the picker for inviting people from the
  workspace open on arrival: an absolute http(s) URL of at most 2000 characters. It lasts however the
  page is moved. CoDNA sends it when `CODNA_PUBLIC_URL` is an http(s) URL. A service may show it to members in
  the call (not to guests), as "Invite people from <workspace>"; one that does not ignores it.
- `Idempotency-Key`: CoDNA's id for the meeting. The same key answers the same meeting, so a start
  that was not heard back from can be sent again without starting two.

```
201 Created

{
  "id": "m_7HxK2",
  "status": "waiting",
  "guest_url": "https://meet.example.com/j/k3Jd9",
  "created_at": "2026-10-19T18:00:00Z"
}
```

The meeting's id is the service's, opaque to CoDNA, at most 200 characters. `guest_url` is the link
anyone can open to join, present when `guests` was true; it lasts as long as the meeting.

### Join

```
POST /v1/meetings/{id}/join
Content-Type: application/json

{ "person": { "id": "0192a4c6-…", "name": "Nino Beridze" } }
```

```
200 OK

{ "url": "https://meet.example.com/j/t_8sKq…", "expires_at": "2026-10-19T18:10:00Z" }
```

CoDNA sends the person's browser to `url`, in a new tab. It is good once, for a few minutes, and opens
the call under their name. CoDNA asks for one each time someone presses Join, never ahead of time. A
meeting that has ended answers `409`.

Who may join is CoDNA's decision, not the service's: whoever can read the meeting's page in CoDNA.

### Ask how a meeting stands

```
GET /v1/meetings/{id}
```

```json
{
  "id": "m_7HxK2",
  "title": "Weekly sync",
  "status": "ready",
  "started_at": "2026-10-19T18:00:41Z",
  "ended_at": "2026-10-19T18:47:02Z",
  "duration": 2781.2,
  "participants": [
    { "id": "0192a4c6-…", "name": "Nino Beridze", "guest": false, "joined_at": "…", "left_at": "…" },
    { "name": "Levan (guest)", "guest": true, "joined_at": "…", "left_at": "…" }
  ],
  "recording": {
    "mix": { "path": "files/mix.ogg", "content_type": "audio/ogg", "bytes": 21345678, "duration": 2781.2 },
    "tracks": [
      { "path": "files/t1.ogg", "content_type": "audio/ogg", "bytes": 8123456,
        "speaker": "Nino Beridze", "offset": 0.0, "duration": 2779.9 },
      { "path": "files/t2.ogg", "content_type": "audio/ogg", "bytes": 6234567,
        "speaker": "Levan (guest)", "offset": 63.4, "duration": 1201.0 }
    ]
  }
}
```

- `status`:
  - `waiting`: nobody has joined yet;
  - `live`: someone is in the call;
  - `ended`: the call is over, and the recording is being put together;
  - `ready`: `recording` can be fetched;
  - `failed`: with `error`, in words for the person who started it.
- A meeting **ends** when everyone has left and two minutes pass, when someone ends it for everyone, or
  after six hours.
- `participants`: everyone who was in the call. `id` is CoDNA's, for a member of the workspace; a
  guest has none.
- `recording` is null when a ready meeting recorded nothing, as when nobody turned their microphone on.
  - `mix` is everyone together, for playing back. It becomes the page's original.
  - `tracks` are one per person and stretch of their speech: someone who left and came back has a
    track for each stay.
  - `path` is relative to the meeting: `files/mix.ogg` is fetched from
    `/v1/meetings/{id}/files/mix.ogg`.
  - `speaker` is the name to transcribe the track under. Two people with the same name are told apart
    by the service ("Nino Beridze (2)").
  - `offset` is where the track starts, in seconds from the start of the mix.

There is no callback. CoDNA asks every half minute while a meeting is waiting, live or ended, and every
two minutes for one nobody has joined in a quarter of an hour. When someone ends a meeting from its
page, CoDNA asks at once.

### Fetch a file

```
GET /v1/meetings/{id}/files/{name}
```

The bytes, with the `content_type` the meeting gave, and their length. CoDNA streams them to disk and
tells audio from the bytes themselves: a mix that is not audio or video ends the meeting.

### End it for everyone

```
POST /v1/meetings/{id}/end
```

`202`. Whoever started the meeting can end it from its page in CoDNA, as from the call itself.

### Let go of it

```
DELETE /v1/meetings/{id}
```

CoDNA calls this once it has fetched the files, before transcribing them, or once it has given up on
the meeting. The service then deletes the recording. `204`, or `404` for one it no longer has. A meeting
that is `ready` and not let go of may be deleted by the service a week later.

### Errors

An error is a status and, where there is something to say, a body: `{ "error": "<words for people>" }`.

- `401`: the token is wrong. A meeting being followed ends, saying whoever runs CoDNA needs to check
  `CODNA_MEET_TOKEN`; starting one is refused with the same words.
- Other `4xx`: the service's answer about this meeting. CoDNA shows `error` and does not ask again.
- `429`, `5xx`, and a connection that fails: try again later. Starting a meeting tells the person to
  try again in a moment. A meeting being followed is asked again, backing off from ten seconds doubling
  to ten minutes, honouring a longer `Retry-After`, for up to a day since the service last answered.
- `404` for a meeting CoDNA is following: the service lost it, and its page says so.

## What CoDNA does with it

1. **New → Start a meeting** starts one at once, named for when it started in the person's own time
   ("Meeting, 19 Oct 15:30"). CoDNA files a raw page for it, opens the call in a new tab for the person
   who started it, and takes this tab to the page. The page says the meeting is in progress and
   carries, for whoever can read it, **Join the call** and the guest link to copy. Whoever started it
   can rename it and end it for everyone.
   - **Invite people** (for whoever may share the page) picks people and groups from the workspace.
     Each is shared the page to read, so they join under their own names, and each person is told
     once: an item in their inbox, and a banner in any CoDNA page they have open, "Nino invites you to
     Weekly sync", with Join and Dismiss, for as long as the meeting is on. Sharing the page by hand
     lets someone join too, without the invitation.
   - What happens to the recording is said once, before anyone joins: the meetings service shows the
     transcription service's notice on its camera check, which members and guests alike pass. The page
     does not repeat it.
2. **While the call is on**, a worker asks the service how it stands, and the page shows who is in the
   call.
3. **Once it is `ready`**, the worker fetches the mix as the page's original and each track beside it,
   then lets go of the meeting at the service. The page's next version says the meeting is being
   transcribed, and the mix plays from the page.
4. **The tracks go to the transcription service as one job** ([Transcription](https://codna.me/docs/transcription.md#send-a-meeting-as-tracks)),
   each under its speaker's name, when the service's policy says it takes tracks. Otherwise, or when a
   meeting has more than 50 tracks, the mix is sent alone, and speakers are told apart by ear.
5. **The transcript becomes the page's next version**, with real names:

```markdown
---
title: Weekly sync
original_filename: weekly-sync.ogg
mime_type: audio/ogg
source: meeting
transcription: done
meeting_started: "2026-10-19T18:00:41Z"
meeting_ended: "2026-10-19T18:47:02Z"
participants: [Nino Beridze, Levan (guest)]
length: "46:21"
length_seconds: 2781
language: ka
engine: gemini-3.5-transcribe
speakers: [Nino Beridze, Levan (guest)]
kept_for_training: true
---

**Nino Beridze** (0:00) Let's start with the budget.

**Levan (guest)** (1:03) I sent the numbers on Friday.
```

The tracks are kept until the transcript arrives, so a transcript that fails can be asked for again
with the speakers' names; then they are dropped, and the mix stays as the original. A meeting that
recorded nothing, failed at the service, or was lost there gets a version saying so. Without a
transcription service, the mix is kept and the page says it was not transcribed.

### REST

- `GET /api/instance` says `"meetings": true` when a meetings service is set up.
- `POST /api/meetings` with `title` starts one. It answers the `recording`, its page included, and a
  `join_url` for the person who started it. `503` when the service cannot be reached.
- `POST /api/documents/{id}/meeting/join`: a link into the call of the meeting a page holds, for
  whoever can read the page.
- `POST /api/documents/{id}/meeting/end`: end it for everyone, for whoever started it.
- `PATCH /api/documents/{id}/meeting` with `title`: rename it, for whoever started it. The page gains
  a version with the new title and nothing else changed.
- `POST /api/documents/{id}/meeting/invite` with `recipients`, people (by email) and groups
  (`group:<slug>`) as sharing names them: shares the page with each to read, without the usual "shared
  with you", and raises a `meeting.invite` notification for each person in them. It answers who was
  `invited`, who was `skipped` and why, and how many people were `told`.
- `GET /api/me/meeting-invites`: the caller's unanswered invitations to meetings still on, for the
  banner. Marking the notification read answers one. A meeting that ends answers all of its own.
- `GET /api/meetings/{id}`: the page of the meeting with that id, for whoever can read it, which is
  where `page_url` leads.
- The page's `recording` carries `meeting`: its `status`, `participants`, `on` while it can be joined,
  `guest_url` while it is on, and `can_end`.

## Writing a service

The least a service has to do:

1. Check the bearer token.
2. `POST /v1/meetings`: make a room, answer an id and a guest link.
3. `POST /v1/meetings/{id}/join`: answer a link into the room under the person's name.
4. Record each person's audio on its own track, and everyone together.
5. `GET /v1/meetings/{id}`: say how it stands, and list the files once it is ready.
6. `GET /v1/meetings/{id}/files/{name}`: serve them.
7. `DELETE /v1/meetings/{id}`: delete them.

`POST …/end` may do nothing but end the room. A service that cannot record separate tracks can list
none: the meeting is transcribed from its mix.
