Agent Participant sends meeting activity as JSON messages. This page covers each event type and how delivery works.
Every message has the same envelope:
{ "category": "message", "name": "<event name>", "data": {} }Dispatch on name. Ignore unknown fields and event names. Fields are snake_case, except inside app-defined broadcast payloads.
Session events are always sent. Other events require their category in the subscriptions query parameter.
| Category | Events |
|---|---|
| Always sent | session.started, session.ended, participant.kick_all |
participant |
participant.join, participant.leave |
transcript |
transcript.received |
broadcast |
broadcast.received |
Pass categories, not event names: ?subscriptions=transcript,participant. An unknown category rejects the connection with 400 INVALID_QUERY.
- Live only. There is no history or snapshot. A connection opened mid-session receives only later events.
- Ordered per source. Transcripts arrive in order, and participant events arrive in order. There is no order across sources.
- At least once on server restart. An event can arrive twice if the server restarts mid-send.
- Queued while you reconnect. Up to 10,000 unsent events are kept per connection and replayed on resume.
- No silent gaps. If the agent loses the meeting, the server closes with
1013upstream_lost. After a rejoin, a secondsession.startedmarks the gap.
To keep receiving events after a dropped WebSocket, send a connection.get request after you connect and store the returned connection.id. When you reconnect, pass it as the connection_id query parameter.
Resume works only when the WebSocket between your backend and the Agent's WebSocket drops unexpectedly, for example after a network error or an abrupt close with a code other than 1000. If your backend closes the WebSocket normally with 1000, the agent leaves the meeting immediately and there is nothing to resume.
What you receive depends on how long you were away:
| Situation | What you receive |
|---|---|
| Resumed within the 5-second grace period | Queued events in their original order, then live events. Nothing is lost. |
| Resumed after the agent left the meeting | A new session.started, then live events. Events from while the agent was out are lost. |
Closed with queue_overflow |
Nothing to resume. More than 10,000 events were queued. Reconnect without connection_id. |
Closed with session_ended after session.ended |
Nothing to resume. Open a new connection for the next session. |
When you handle events:
- Treat a second
session.startedas a gap. Joins, leaves, and transcripts from the gap are not replayed, so your participant list may be out of date. - Make handlers idempotent. An event can arrive twice if the server restarts while sending it.
- Changing
subscriptionson resume does not re-filter queued events. Events already queued under the old list are still delivered.
For close codes and resume behavior, refer to Reconnect.
Sent once the agent joins a live session. Subscribed events follow it.
{
"category": "message",
"name": "session.started",
"data": {
"session": {
"id": "ses_1",
"meeting_id": "mtg_1",
"started_at": "2026-09-23T10:00:00.000Z",
"ended_at": null
}
}
}session.id matches the session REST APIs and webhooks.
Sent when the session ends or the agent is kicked. The server then closes with 1000 session_ended. To follow the next session, open a new connection.
{
"category": "message",
"name": "session.ended",
"data": {
"session": {
"id": "ses_1",
"meeting_id": "mtg_1",
"started_at": "2026-09-23T10:00:00.000Z",
"ended_at": "2026-09-23T10:45:12.000Z"
},
"reason": "host_ended_meeting"
}
}reason |
Meaning |
|---|---|
all_participants_left |
The last participant left and the close timeout elapsed |
host_ended_meeting |
A host ended the meeting for everyone |
force_cleanup |
The platform closed an orphaned session |
Treat unknown reasons as all_participants_left. A late final transcript can be missed after session.ended.
A connected agent counts as a participant, so the session does not end on its own while the agent is connected. Refer to Billing and ending the session.
A host removed every participant. No participant.leave events are sent for it. session.ended follows.
{ "category": "message", "name": "participant.kick_all", "data": {} }Participant events are per connection, keyed by peer_id. A participant on two devices produces two joins with the same participant_id and different peer_id values. A reconnect gets a new peer_id.
{
"category": "message",
"name": "participant.join",
"data": {
"participant": {
"peer_id": "peer_7f3a",
"participant_id": "par_1",
"meeting_id": "mtg_1",
"custom_participant_id": "customer-user-1",
"display_name": "Alice",
"picture_url": null,
"preset_id": "pst_1",
"preset_name": "group_call_host",
"waitlisted": false
}
}
}| Field | Description |
|---|---|
peer_id |
This connection |
participant_id |
Participant ID, as in the REST API |
custom_participant_id |
Your ID for the participant, or null |
display_name |
Display name |
picture_url |
Display picture URL, or null |
preset_id |
Preset ID, or null |
preset_name |
Preset name, or null |
waitlisted |
true while the connection waits for host approval |
Same fields as participant.join, without preset_name. Match it to the join by peer_id.
On a kick, only peer_id and meeting_id are set. Other fields are null, and waitlisted is false.
- Participants already present when the agent joined. There is no initial list.
- The agent's own join and leave.
- Hidden, recorder, and livestreamer connections.
Requires real-time transcription on the speakers' presets.
{
"category": "message",
"name": "transcript.received",
"data": {
"text": "Hello everyone.",
"is_final": true,
"started_at": "2026-09-23T10:00:03.000Z",
"ended_at": "2026-09-23T10:00:05.000Z",
"confidence": 0.94,
"speaker": {
"peer_id": "peer_7f3a",
"participant_id": "par_1",
"custom_participant_id": "customer-user-1",
"display_name": "Alice"
}
}
}| Field | Description |
|---|---|
text |
Transcribed text |
is_final |
true when the result is final. It does not mean the speaker's turn ended. |
started_at |
When the server received the transcript, to the second. Not when speech started. |
ended_at |
When speech ended, to the second |
confidence |
Speech-to-text confidence, 0 to 1 |
speaker |
The speaking connection. peer_id matches participant.join. |
Partials (is_final: false) can be followed by a final for the same speech. There is no segment ID. Replace the latest partial per speaker, and store only finals.
Broadcasts are custom messages that participants send to the meeting. Any type and payload your app sends arrive at the agent, so broadcasts are how you feed app-specific signals to an agent.
Send broadcasts from any participant with the broadcast APIs:
await meeting.participants.broadcastMessage("my-broadcast", {
message: "Greetings!",
peerId: meeting.self.peerId,
});Reactions already work this way. The Reactions Manager addon sends each reaction as a broadcast with type reactions.
{
"category": "message",
"name": "broadcast.received",
"data": {
"type": "reaction",
"payload": { "emoji": "🔥", "peerId": "peer_7f3a" },
"created_at": "2026-09-23T10:03:10.123Z"
}
}| Field | Description |
|---|---|
type |
App-defined type, for example reactions |
payload |
App-defined JSON object, forwarded as sent |
created_at |
When the broadcast was sent |
- Filter on
type. For reactions,payload.reactionis the emoji, and an empty string clears it. payloadkeys are forwarded as sent, so they keep your app's casing, such aspeerId.- The sender is not verified. Do not trust identity fields in
payloadfor authorization. - The SDK also sends internal types, such as
spotlight. Ignore types you do not handle.