I'm seeing my rooms disconnect with ROOM_CLOSED ev...
# ask-ai
s
I'm seeing my rooms disconnect with ROOM_CLOSED event even when I have not done anything. What could be causing this? I'm on Livekit cloud and can see the last events are "Participant left (ROOM_CLOSED)" and then Room Ended - not clear what is closing the room
t
Thanks for reaching out, Michael. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I understand you’re seeing participants get disconnected with a "ROOM_CLOSED" event on LiveKit Cloud and want to know why rooms are ending when you didn’t trigger anything. Short answer: ROOM_CLOSED means the room was deleted via the DeleteRoom API (server-side), not an idle timeout or normal participant departure. What most commonly causes this • A backend or admin script is calling DeleteRoom (direct API/Twirp/gRPC calls). • An Agent, tool, or shutdown hook is deleting the room (Agents often include room-cleanup logic). • A webhook handler or automation that deletes empty rooms or enforces session limits. Immediate steps to diagnose 1. Search your server logs for DeleteRoom / RoomService.DeleteRoom calls or the Twirp path
/twirp/livekit.RoomService/DeleteRoom
. 2. Check any Agent code, tools, or shutdown hooks for `delete_room()`/`room.delete_room()` calls. 3. Inspect webhook handlers (e.g., on participant_left) and any cleanup/cron scripts that might delete rooms. 4. Correlate timestamps: match the room_finished webhook time with any backend task logs around the same time. If you want, tell me: • whether you use Agents, webhooks, or scheduled cleanup jobs, • and a recent room ID and timestamp— and I’ll suggest targeted places to look in your code/logs and next steps to prevent unintended deletes. Sources: Job lifecycle | LiveKit docs | Server-side | livekit/livekit_composite | DeepWiki | Avoiding Room ID Reuse When Re-Creating Rooms With the Same Name | server-sdk-js | livekit/livekit_composite | DeepWiki | Agents telephony integration | LiveKit docs
s
I'm not using Agents or scheduled cleanup jobs. I use webhooks but they don't delete rooms nor close rooms A recent room session ID is RM_Zce5WCj6bTDR - The room was created at Dec 31, 2025, 54433 PM - and then auto closed 5 minutes later
t
Thanks — I’m looking at the RM_Zce5WCj6bTDR details you shared. Since you’re not using Agents or scheduled jobs and your webhooks don’t intentionally delete rooms, focus on these targeted checks because ROOM_CLOSED means the room was deleted via the DeleteRoom API: 1. Search server logs for any calls to DeleteRoom / RoomService.DeleteRoom or the Twirp path
/twirp/livekit.RoomService/DeleteRoom
around Dec 31, 2025 5:49 PM (5 minutes after creation). 2. Inspect webhook handlers again for any indirect delete behavior — e.g., a handler that on
participant_left
or another event triggers code path that eventually calls DeleteRoom, or throws an error that a retry/cleanup routine interprets as a signal to delete. 3. Check any admin/UI actions or automation (CI, deploy hooks, admin dashboards) that might call the API — include API keys used and who/what has access. 4. Verify your backend error logs and crash/restart hooks: some services call DeleteRoom during shutdown or error recovery. 5. If you have multiple services/environments, confirm the request source by looking for request metadata (client IP, API key, user id) in logs to identify which service made the DeleteRoom call. If you’d like, share: • any webhook event logs around the room end time, • the server log snippet showing requests at that timestamp, • or which API key/client might have been active, and I’ll help interpret the logs and point to the exact code path likely issuing the DeleteRoom.
s
our server nowhere calls DeleteRoom endpoint of livekit client - nowhere at all - there's no call to DeleteRoom anywhere And our particpant_left and other handlers like egress_stopped - only delete records on our side or stop egress, no room deletion or anything similar
t
Thanks — I hear you: you don’t call DeleteRoom anywhere, webhooks don’t delete rooms, yet RM_Zce5WCj6bTDR was auto-closed ~5 minutes after creation. Let’s narrow this down. Quick facts to check (these can cause ROOM_CLOSED even without explicit DeleteRoom calls) • Rooms can auto-close when last participant leaves or when room lifecycle options (e.g., close_on_disconnect / delete-on-empty) are enabled. • A participant disconnect (including egress/SIP/agent participants) can trigger room closure depending on configuration. • Some shutdown hooks or hidden service code paths (eg. hangup flows, egress completion handlers) may indirectly cause session cleanup. Please provide these items so I can pinpoint the cause: 1. The webhook event log for room RM_Zce5WCj6bTDR around Dec 31, 2025 544–550 PM (all events like participant_joined/left, egress_started/stopped, room_finished). 2. The
participant_left
event reason(s) and timestamps for that room (what reason appears — e.g., CLIENT_INITIATED, USER_REJECTED, ROOM_DELETED). 3. Any room creation options you set (e.g.,
close_on_disconnect
,
delete_room_on_close
, or similar RoomOptions). 4. Whether you create any egress, SIP calls, or use any short-lived service connections for this room. If you share the webhook log or the participant_left reason and timestamps, I’ll analyze them and point to the exact likely trigger and the next remediation (e.g., disable auto-close, keep a dummy participant, or adjust your egress/hangup logic). Sources: Agent session | LiveKit docs | Server lifecycle | LiveKit docs | Agents telephony integration | LiveKit docs
s
1. Attached is a screenshot of the events - as you can see Participant_left is being triggered on ROOM_CLOSED 2. The particpaent_left reason is ROOM_CLOSED 3. We create room by having the first user join with a valid token - here's the token creation code
Copy code
func (core Context) CreateJoinToken(roomName string, identity string) (JoinStudioResponse, error) {
    token := auth.NewAccessToken(core.LiveKitConfig.Key, core.LiveKitConfig.Secret)
    grant := &auth.VideoGrant{
       RoomJoin:  true,
       Room:      roomName,
       RoomAdmin: true,
       Recorder:  true,
    }
    token.AddGrant(grant).SetIdentity(identity).SetValidFor(kLivekitTokenLifetime)
    tokenStr, err := token.ToJWT()

    if err != nil {
       return JoinStudioResponse{}, err
    }
    response := JoinStudioResponse{
       Token:        tokenStr,
       RoomName:     roomName,
       HostIdentity: identity,
    }
    return response, nil
}
Maybe RoomCreate - should be true for the particpant..? 4. We do create egress for the room
t
Thanks — I see: webhook shows Participant_left with reason
ROOM_CLOSED
, your token grants include
Recorder: true
, and you do create egress. That strongly narrows it down. Short diagnosis •
ROOM_CLOSED
means the room was deleted server-side (same outcome as DeleteRoom). Deleting the room can happen indirectly when the last non-agent participant and any recorder/egress participants finish, or if room options instruct auto-close/delete on participant disconnect or egress finish. • Because you enable the Recorder grant and you create egress, the recorder/egress lifecycle is the prime suspect: if egress finishes or is configured to stop the session (or your egress creation requests include flags that delete the room on completion), LiveKit can delete the room which produces
ROOM_CLOSED
. Next steps — please provide two things so I can pinpoint and give the exact fix: 1. The egress creation code/parameters you use (especially any flags like stop_on_exit, stop_on_finish, or delete_room_on_finish). 2. Any RoomOptions you set when creating/joining the room (e.g., close_on_disconnect / delete_room_on_close). What I’ll check once you share those • Whether egress completion is triggering a room delete and which option causes it. • If the Recorder grant + egress behavior requires changing token grants, keeping a short-lived “keeper” participant, or adjusting egress flags / room options. If you prefer, paste the egress request JSON or code snippet and your room creation/join options and I’ll point to the exact field to change.
s
Here's the egress request code
Copy code
func (core Context) StartTrackCompositeRecording(roomName string, assetId string, projectId string, userId string, audioTrackId string, videoTrackId string, trackTitle string, storageBucketName, languageCode, participantSid string, isHost bool) (string, error) {

    trackCompositeId := GenerateTrackID()
    filenameForInitialUpload := KPubsubMediaTypeLivekitRecording + "-" + assetId + "-" + trackCompositeId
    request := &livekit.TrackCompositeEgressRequest{
       RoomName:     roomName,
       AudioTrackId: audioTrackId,
       VideoTrackId: videoTrackId,
       Output: &livekit.TrackCompositeEgressRequest_File{
          File: &livekit.EncodedFileOutput{
             FileType: livekit.EncodedFileType_MP4,
             Filepath: filenameForInitialUpload,
             Output:   core.GetLivekitEgressOutput(storageBucketName),
          },
       },
    }

    egressInfo, err := core.LivekitEgressClient.StartTrackCompositeEgress(core.BackgroundContext, request)
    if err != nil {
       newErrMsg := "Error starting track composite egress: " + err.Error()
       return "", errors.New(newErrMsg)
    }

    err = core.CreateCloudRecordingEntry(trackCompositeId, assetId, egressInfo.EgressId, roomName, participantSid, isHost)
    if err != nil {
       newErrMsg := "Error creating cloud recording entry: " + err.Error()
       return "", errors.New(newErrMsg)
    }

    recordingType := TrackComposite
    fileExt := ".mp4"
    _, err = core.CreateTrack(trackCompositeId, assetId, projectId, userId, egressInfo.EgressId, trackTitle, recordingType, "Recording in progress", storageBucketName, trackCompositeId+fileExt, languageCode, false)
    if err != nil {
       newErrMsg := "Error creating track composite: " + err.Error()
       return "", errors.New(newErrMsg)
    }

    return egressInfo.EgressId, nil
}
Why would egress complete - we don't call stop egress until the host participant leaves - and the logs show the participant never left until the ROOM_CLOSED trigger disconnect
t
Thanks — this is helpful. You're starting TrackComposite egress which creates a recorder/egress participant. That recorder participant leaving (egress finishing) can cause the room to be deleted and produce the
ROOM_CLOSED
participant_left reason even though you never call DeleteRoom directly. What to check and quick actions to try • Remove the
Recorder: true
grant from the token and issue recorder/egress with a separate token if you don't need the hidden recorder as a participant. • Confirm egress options/flags: ensure you're not setting any "stop on finish" or "delete room on finish" behavior in the egress request (your code above doesn't show such flags, but check any defaults in your egress client). • Keep the host (or a lightweight keep-alive bot) connected while egress runs so the room isn't empty when the recorder participant leaves. • Verify you don't have room options enabled that auto-delete/close on disconnect (e.g., close_on_disconnect / delete-on-empty). Please tell me: 1) Do you need the Recorder grant on the user's token, or can recorder/egress use a separate token? 2) Can you share any egress/default room option configuration (or confirm none are set)? If you want, I can recommend the exact minimal change (remove
Recorder: true
or keep a dummy participant) once you confirm those two items.
s
I don't need the recorder: true so removed it. What do you mean issue recorder/egress with a separate token? I make the egress request from my backend - i.e with backend client - no tokens I guess
t
Thanks — this is helpful. You're on the right track removing
Recorder: true
. To clarify, and to confirm next steps: • Egress started from your backend using the server SDK is authenticated with your LiveKit API key/secret, not a participant JWT. You do not need a special token for egress when you call it from the backend. • The Recorder grant on a participant token creates a hidden recorder participant in the room. If that recorder/egress participant leaves or finishes and the room is configured to close on recorder/egress completion (or becomes empty), it can produce the
ROOM_CLOSED
disconnect you saw. • Removing
Recorder: true
from the user token should prevent creating that recorder participant on join and reduce the chance of the recorder triggering a room delete. No separate recorder token is required for your backend egress calls. Two quick checks to finish: 1. Confirm you’ve removed
Recorder: true
from issued tokens and redeployed. 2. Verify room options aren’t set to auto-delete on empty/egress finish (e.g.,
close_on_disconnect
/ delete-on-empty flags). If the ROOM_CLOSED still occurs after that, please share the webhook events around the room end (participant_left reasons and timestamps) and the egress finished/egress stopped webhook — I’ll analyze them and point to the exact trigger. Sources: Python SDKs | livekit/livekit_composite | DeepWiki | server-sdk-js | livekit/livekit_composite | DeepWiki | server-sdk-ruby | livekit/livekit_composite | DeepWiki | Agent dispatch | LiveKit docs
s
Seems like that was the issue because current session with egress has been going on for 9 mins without room closing - so yea recorder:true was the issue very likely Can you audit similarly the token creation for guests code
Copy code
func (core Context) CreateJoinTokenForGuest(roomName string, identity string) (JoinStudioResponse, string, error) {
    token := auth.NewAccessToken(core.LiveKitConfig.Key, core.LiveKitConfig.Secret)
    grant := &auth.VideoGrant{
       RoomJoin:   true,
       Room:       roomName,
       RoomCreate: false,
    }
    participantIdentifier := GenerateParticipantIdentifier()
    token.AddGrant(grant).SetIdentity(identity).SetValidFor(kLivekitTokenLifetime)
    tokenStr, err := token.ToJWT()

    if err != nil {
       return JoinStudioResponse{}, "", err
    }
    response := JoinStudioResponse{
       Token:                 tokenStr,
       RoomName:              roomName,
       ParticipantIdentifier: participantIdentifier,
    }
    return response, participantIdentifier, nil
}
t
Thanks — I reviewed your guest token code. What’s good • You only grant
RoomJoin: true
and
RoomCreate: false
— that’s correct for guests who should only join existing rooms. • No `Recorder`/recording grant is present, so this token will not create a hidden recorder participant that could trigger ROOM_CLOSED. • Token lifetime is applied via
SetValidFor(kLivekitTokenLifetime)
— ok as long as that value matches your session expectations. • You generate a separate
participantIdentifier
, which is fine for your app logic. Recommendations (if needed) • If guests must publish or subscribe, explicitly set those fields on the
VideoGrant
(e.g.,
CanPublish
,
CanSubscribe
) rather than relying on defaults. • Keep tokens scoped minimal (avoid room admin/recorder unless required). • If you need very short sessions, reduce `kLivekitTokenLifetime`; for long-lived sessions consider a refresh flow. This token config should not cause ROOM_CLOSED events. Do you want me to suggest exact
VideoGrant
fields for a typical publishing guest (mic/camera) or review any other token flows?
s
Okay how should the guest token be created (what options) so that a guest can't create a room by joining it - but can only join an already created room?
t
Good question — you’re already almost right. To let a guest join an existing room but prevent them from creating rooms, issue a token with: •
RoomJoin: true
(allow joining) •
RoomCreate: false
or omit it (prevent creating) • Do NOT set
RoomAdmin: true
or
Recorder: true
• Explicitly set publish/subscribe capabilities if needed (e.g., allow mic/camera): set
CanPublish
/
CanPublishData
/
CanSubscribe
on the
VideoGrant
as appropriate Example Go code (adjust `CanPublish`/`CanSubscribe` to match your guest permissions):
Copy code
token := auth.NewAccessToken(core.LiveKitConfig.Key, core.LiveKitConfig.Secret)
grant := &auth.VideoGrant{
  RoomJoin:   true,
  RoomCreate: false,      // prevents creating new rooms
  // CanPublish: true,    // enable if guests should publish audio/video
  // CanSubscribe: true,  // enable if guests should receive tracks
}
token.AddGrant(grant).SetIdentity(identity).SetValidFor(kLivekitTokenLifetime)
tokenStr, _ := token.ToJWT()
Notes and best practices • Keep tokens minimal: only include the permissions guests actually need. • Never include
RoomAdmin
or
Recorder
in guest tokens. • Room creation control is enforced server-side by the
RoomCreate
flag in the grant; guests with
RoomCreate: false
cannot create rooms by joining. • If you need further granularity, explicitly set `CanPublish`/`CanSubscribe` instead of relying on defaults. If you want, tell me what guest capabilities you want (e.g., mic + camera only, or view-only) and I’ll give the exact grant fields.