You build a voice agent, connect from the browser, and have a great conversation. Then the user hits refresh, and the agent is gone. Reconnect, and it has no memory of what you just said. In your agent logs you see:
1livekit.agents - closing agent session due to participant disconnect
This is working as designed, but the default is tuned for telephony, not for long-lived web and mobile sessions. This guide explains what's actually happening, how to keep the agent in the room across a reconnect, and (because this is the part people skip) when you should not change the default.
Background: two separate things end a session#
When a browser refreshes, the WebRTC connection drops with the disconnect reason CLIENT_INITIATED. Two independent mechanisms react to that, and to keep an agent alive you have to understand both.
1. The agent SDK closes the session (the immediate cause)#
AgentSession talks to a single linked participant, which by default is the first non-agent participant to join. RoomIO watches that participant, and when they disconnect it closes the session:
1# livekit-agents/livekit/agents/voice/room_io/room_io.py2def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None:3if not (linked := self.linked_participant) or participant.identity != linked.identity:4return5self._participant_available_fut = asyncio.Future[rtc.RemoteParticipant]()67if (8self._options.close_on_disconnect9and participant.disconnect_reason in DEFAULT_CLOSE_ON_DISCONNECT_REASONS10and not self._close_session_atask11and not self._delete_room_task12):13logger.info("closing agent session due to participant disconnect ...")14self._agent_session._close_soon(reason=CloseReason.PARTICIPANT_DISCONNECTED)
close_on_disconnect defaults to True, and the reasons that trigger it are:
1# livekit-agents/livekit/agents/voice/room_io/types.py2DEFAULT_CLOSE_ON_DISCONNECT_REASONS: list[rtc.DisconnectReason.ValueType] = [3rtc.DisconnectReason.CLIENT_INITIATED,4rtc.DisconnectReason.ROOM_DELETED,5rtc.DisconnectReason.USER_REJECTED,6]
A refresh is CLIENT_INITIATED, so the session closes immediately, before any room timeout matters. That's the log line above, and it's why the reconnected page meets a fresh, amnesiac agent. This option is documented under Agent session → Clean up options.
2. The server closes the room when the last human leaves#
Separately, LiveKit Cloud manages the room's lifecycle. The rule is specific: a room "is automatically closed when the last non-agent participant leaves" (Server lifecycle). Agents do not keep a room alive. Once the last human leaves, the room closes after a short grace period, the departureTimeout (default 20 seconds), and "any remaining agents disconnect."
This is the subtlety that trips people up: emptyTimeout and departureTimeout are real, but they don't override the agent-side close_on_disconnect, and they key off humans, not agents. So even if you stop mechanism #1, the room itself is on a countdown the moment your user disconnects.
A browser refresh is a disconnect followed by a reconnect a second or two later. To survive it you must (a) stop the agent SDK from closing the session on the disconnect, and (b) keep the room open long enough for the user to come back. Both, or it still dies.
The whole decision, from a disconnect to what happens to the agent, fits in one picture:
Loading diagram…
Keeping the agent alive across a reconnect#
Step 1: turn off close_on_disconnect#
Pass it through RoomOptions (Python) / RoomInputOptions (Node.js) when you start the session:
1from livekit.agents import room_io23await session.start(4agent=my_agent,5room=ctx.room,6room_options=room_io.RoomOptions(close_on_disconnect=False),7)
1// Node.js2await session.start({3agent: myAgent,4room: ctx.room,5inputOptions: { closeOnDisconnect: false },6});
Now the disconnect no longer tears down the session. The agent keeps its conversation history, so when the user returns it still remembers what they said.
Step 2: give the room a departureTimeout that covers the gap#
Because the server starts the room-close countdown when the last human leaves, set a departureTimeout long enough to outlast a reconnect. A refresh reconnects in a second or two, but for an interview agent where a candidate might lose Wi-Fi or step away, you may want minutes. Configure it in the room configuration you attach to the token (or via CreateRoom):
1from livekit.api import RoomConfiguration23# In the token used by the browser client4room_config = RoomConfiguration(5departure_timeout=300, # keep the room open 5 min after the last human leaves6empty_timeout=300, # and 5 min if no one ever joins7)
Step 3: reconnect with the same identity#
When the user reconnects, they must use the same participant identity. RoomIO re-resolves its linked participant when someone rejoins, so a matching identity re-links the agent to the same person automatically. A new random identity each load will look like a different user.
The result: refresh the page, reconnect, and the agent is still there, mid-conversation.
In-session memory vs. remembering across sessions#
Keeping the session alive solves in-session memory. The agent holds the conversation in its ChatContext for as long as the AgentSession lives, so a refresh that reconnects in time finds everything intact. But that memory is still tied to one session. Once the room finally closes (the user is gone past departureTimeout, or closes the tab for good), the session ends and that context is gone. The next time the same person connects, they get a brand-new session that starts from zero.
If you want the agent to greet a returning user by name or recall what they said last week, you need persistent memory: write the relevant facts to a database keyed by a stable user id, then load them back at the start of the next session. LiveKit gives you the two hooks this needs. Identify the user from job metadata before ctx.connect() and pre-load their profile into the ChatContext, then persist a session report on hangup with the on_session_end callback.
For a complete, working example, see MongoDB Vector Search for Voice Agents, which wires Atlas into a LiveKit agent for per-user memory and includes a clonable starter kit. A demo video shows it in action.
close_on_disconnect=False handles "the user will be right back." Persistent memory handles "the user will be back next week." They're complementary: keep the session alive across a blip, and write durable facts to a database so a fresh session can pick up where the last one ended.
Don't strand the agent: how it should leave#
Turning off close_on_disconnect creates a new risk: an agent that never leaves, holding a worker process and live STT/LLM/TTS connections (and the bill that comes with them). Pair it with a cleanup path. You have three options, and they compose:
Let the room timeout do it (simplest). With close_on_disconnect=False and a departureTimeout of, say, 300s, the agent survives short blips but the server still closes the room (and disconnects the agent) once the user is gone past the timeout. Cleanup is automatic and bounded.
Detect an absent user in the agent (most control). Use user_away_timeout with the user_state_changed event to notice silence, prompt a few times, then shut down:
1import asyncio2from livekit.agents import AgentSession, UserStateChangedEvent34session = AgentSession(user_away_timeout=15.0) # seconds of silence → "away"56inactivity_task: asyncio.Task | None = None78async def check_if_user_present():9for _ in range(3):10await session.generate_reply(11instructions="The user has been quiet. Politely check if they're still there."12)13await asyncio.sleep(10)14session.shutdown() # give up and end the session1516@session.on("user_state_changed")17def _on_user_state_changed(ev: UserStateChangedEvent):18global inactivity_task19if ev.new_state == "away":20inactivity_task = asyncio.create_task(check_if_user_present())21elif inactivity_task:22inactivity_task.cancel()23inactivity_task = None
Delete the room immediately on close. If you want the room gone the instant the session ends rather than lingering for the grace period, set delete_room_on_close=True.
If an agent disconnects unexpectedly (OOM, crash), LiveKit detects it within about 15s and dispatches a fresh agent to the room. That re-dispatched agent is a brand-new session with no memory. Surviving a refresh relies on keeping the same session alive with the settings above, not on re-dispatch.
Telephony vs. web: branch on the participant kind#
close_on_disconnect is a single session-level setting (a plain bool on RoomOptions), not something you can scope per participant. It applies to whichever participant the session is linked to, whether that's a STANDARD web user or a SIP caller. That matters because the same disconnect reason means opposite things for the two kinds:
STANDARD(browser, mobile): aCLIENT_INITIATEDdisconnect is often just a refresh or a brief network blip. You usually want to stay.SIP(telephony): a clean hangup (a SIPBYEfrom either side) also surfaces asCLIENT_INITIATED(Handling mid-call disconnections). But a hangup is final: there is no "reconnect," and you want to end the session and free the worker right away.
So you can't tell intent from the disconnect reason alone. You have to look at the participant kind.
If your agent only ever serves one kind, pick the matching default: web and mobile get close_on_disconnect=False plus a cleanup path, while pure telephony keeps the default close_on_disconnect=True so a hangup ends the call immediately.
If a single agent serves both (a common setup, since the same entrypoint can take inbound calls and web sessions), keep close_on_disconnect=False for the web case and add a participant_disconnected handler that shuts down when the caller is a SIP participant:
1from livekit import rtc23# With close_on_disconnect=False, the agent survives web refreshes.4# A phone hangup, though, is final: end the session right away.5@ctx.room.on("participant_disconnected")6def on_participant_disconnected(participant: rtc.RemoteParticipant):7# A clean SIP hangup (BYE) arrives as CLIENT_INITIATED, the same reason a8# browser refresh uses, so branch on the participant kind, not the reason.9if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP:10session.shutdown()
Identify SIP callers with participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP (see Identifying SIP callers). For richer SIP-only behavior, inspect the SIP attributes such as sip.phoneNumber.
When to keep close_on_disconnect=False, and when not to#
This is the decision that matters. Setting it the wrong way either frustrates users or quietly burns money.
Keep the agent alive (close_on_disconnect=False) when:#
- Browser and mobile sessions where a refresh, tab switch, or network blip is routine and expected.
- Long or high-stakes sessions such as interviews, tutoring, or support, where a momentary drop shouldn't throw away the conversation.
- Multi-party rooms where one participant leaving shouldn't end the agent for everyone else.
In all of these, always pair it with a cleanup path (a sane departureTimeout, an away-timeout shutdown, or both).
Leave the default (close_on_disconnect=True) when:#
- Telephony / SIP. A hangup is the end of the call. You want the session to close immediately and free the worker. This is exactly why the default exists; see Handling mid-call disconnections, where
CLIENT_INITIATED,ROOM_DELETED, andUSER_REJECTEDare treated as call-ending. If one agent handles both telephony and web, see the section above on branching by participant kind. - Single-shot tasks where a disconnect genuinely means "done."
- Cost-sensitive workloads. Every open session holds a worker process and live model connections. If you keep sessions open on disconnect without a reliable shutdown path, a flaky network can leave a pile of orphaned agents running indefinitely.
The short version: close_on_disconnect=True optimizes for freeing resources fast; False optimizes for not losing the conversation. Pick based on whether a disconnect in your app means "the user is done" or "the user will be right back." If you pick False, make sure something still ends the session when they're truly gone.
Related resources#
- Agent session → Clean up options:
close_on_disconnectanddelete_room_on_close - Handling inactive users:
user_away_timeoutand the away/shutdown pattern - Server lifecycle: when rooms close and agents disconnect
- Room management: room lifecycle and creation
- Room service API reference: the
emptyTimeoutanddepartureTimeoutfields (with defaults) onCreateRoomandRoomConfiguration - Events and error handling:
CloseReason,user_state_changed, and session events - Types of participants: the
STANDARD,SIP, andAGENTparticipant kinds - Identifying SIP callers: branching on
participant.kindfor telephony - MongoDB Vector Search for Voice Agents: persistent per-user memory that survives across sessions
- Handling mid-call disconnections (telephony): why the default closes on
CLIENT_INITIATED room_io.pyandtypes.py: the disconnect-handling source