WarmTransferTask: how to tear down a 2-party SIP room when one party hangs up after the agent has left?

Environment: @livekit/agents 1.4.7 (Node), @livekit/rtc-node 0.13.29, livekit-server-sdk 2.15.3. Inbound PSTN via SIP (Twilio trunk). Using beta.WarmTransferTask.

What we’re doing (warm transfer):

  1. Caller (SIP) is in room R with our agent.

  2. WarmTransferTask dials a human rep into a side room, briefs them, then connect_to_caller → moveParticipant merges the rep (human-agent-sip) into room R.

  3. The AI agent then closes its AgentSession (session.shutdown) so it stops listening/talking — otherwise, after the task completes, control hands back to our default agent and it interjects into the human-to-human conversation.

Now room R holds two SIP legs (caller + rep) talking directly, AI gone.

The problem: LiveKit doesn’t couple the two SIP legs. When one party hangs up, the other is left alone in R. The room is still non-empty (1 participant), so departureTimeout/empty-reap never fires — the surviving PSTN leg sits in silence and keeps billing until that person also hangs up. We want: when either party leaves a post-transfer room, drop the other leg / end the room.

Why the SDK’s built-in teardown doesn’t fire for us: WarmTransferTask registers onCallerParticipantDisconnected, which on a participant leaving calls:

const rooms = new RoomServiceClient(getJobContext().info.url);

void rooms.deleteRoom(this._callerRoom.name)…

But because we closed the agent session at the bridge (step 3), by the time a party hangs up the job context is gone. The handler fires and throws:

Unhandled promise rejection: no job context found, are you running this code inside a job entrypoint?

at getJobContext (@livekit/agents/src/job.ts:49)

at Room.onCallerParticipantDisconnected (@livekit/agents/src/beta/workflows/warm_transfer.ts:341)

So deleteRoom never runs → caller leg orphaned. (Confirmed on a live call: rep hung up, caller leg stayed up.)

The core tension: we must close/silence the agent at the bridge (or it talks over the humans), but the SDK’s room teardown depends on the job context that closing tears down.

Our question: What’s the recommended way to tear down a post-warm-transfer room (and drop the surviving SIP leg) when one of two SIP participants leaves, after the agent has stepped out? Specifically:

  1. Is the intended design to keep the AgentSession alive (so getJobContext() works and the built-in onCallerParticipantDisconnected fires)? If so, what’s the supported way to make the agent silent/non-interjecting post-merge (mute input/output, no LLM turns) while keeping the job alive — and does an idle-but-open session keep billing?

  2. Is onCallerParticipantDisconnected calling getJobContext() in an event callback that can outlive the entrypoint a known issue in WarmTransferTask? Should it capture the RoomServiceClient/URL at construction instead?

  3. Is the server-side participant_left webhook (we list participants, and deleteRoom when a lone SIP leg remains) the recommended pattern here, rather than relying on the in-process handler?

  4. Is there a native room/SIP option to couple the legs so hanging up one ends the other — e.g., end-room-when-participant-count-drops-below-N, a max-duration, or SIP bridge semantics — so we don’t need an external reaper at all?

Hi, there is no mechanism for automatically closing a room after N>0 human participants remain, the way I would solve this is by using the participant_left webhook, then call listParticipants and, of that returns 0, deleteRoom. So, your option 3.

Regarding your first option, yes, a silent agent left in the room would count towards your agent session minutes. I’m sure that option would work, but the 3rd option feels cleaner.

Thanks, we’ll go with the `participant_left` webhook + `listParticipants`/`deleteRoom` reaper (option 3), and we’ll keep the agent closing at the bridge.

One thing your reply didn’t cover, and it’s independent of the reaper: `WarmTransferTask` itself still registers `onCallerParticipantDisconnected` on the caller room after the merge. Because we close the AgentSession at the bridge (so the agent doesn’t talk over the humans), by the time the caller hangs up the job context is gone, and that handler throws:

 `Unhandled promise rejection: no job context found, are you running this code inside a job entrypoint?`

 `at getJobContext (@livekit/agents/src/job.ts:49)`

 `at WarmTransferTask.onCallerParticipantDisconnected (@livekit/agents/src/beta/workflows/warm_transfer.ts:341)`

So regardless of our external reaper, the SDK’s own handler fires and throws an unhandled rejection on every caller hang-up after a transfer (confirmed on a live call; still present in 1.4.9).

Is this a known issue in `WarmTransferTask`? Calling `getJobContext()` lazily inside an event callback that can outlive the entrypoint looks unsafe, should it capture the `RoomServiceClient`/URL at construction/merge time, and/or no-op when the context is already gone?

I’ll try to reproduce and get that resolved - I haven’t tried WarmTransferTask with our Node agents before.

I believe you’re not blocked though?

A quick update after more live testing: we are still blocked on a reliable teardown path.

We implemented the suggested `participant_left` webhook reaper, but it did not tear down the original caller room in live tests when one party disconnected after the bridge. In both directions, the surviving SIP leg stayed connected.

What we observed:

1. Warm transfer bridge succeeds.

2. The AI `AgentSession` closes with `transfer_completed` so it does not speak over the human-to-human call.

3. The `*-human-agent` consultation room is deleted normally.

4. Later, when either post-bridge SIP participant hangs up, `WarmTransferTask` does observe the original caller-room disconnect:

  • transfer destination hangs up: `participantIdentity: “human-agent-sip”`

  • original caller hangs up: `participantIdentity: “sip_…”`

5. Then the SDK handler throws before deleting the room:

`no job context found, are you running this code inside a job entrypoint?`

Stack:

`getJobContext (@livekit/agents/src/job.ts:49)`

`Room.onCallerParticipantDisconnected (@livekit/agents/src/beta/workflows/warm_transfer.ts:341)`

I checked the Node SDK source. `WarmTransferTask.onEnter()` captures `jobCtx.room` into `_callerRoom`, and after `connect_to_caller` succeeds it registers:

`_callerRoom.on(RoomEvent.ParticipantDisconnected, this.onCallerParticipantDisconnected)`

But the later callback constructs the room client with:

`new RoomServiceClient(getJobContext().info.url)`

That callback can fire after our `AgentSession` has already been shut down at bridge time, so `getJobContext()` throws before `deleteRoom()` can run.

So there are two separate issues:

1. The webhook reaper did not work reliably for us in live testing.

2. Independently, the SDK’s own post-merge cleanup listener fires, but throws an unhandled rejection because it lazily calls `getJobContext()` after the context is gone.

For the webhook reaper specifically, I also think the delete condition should be “exactly one remaining SIP participant,” not 0. The orphaned-leg problem is a non-empty room with one PSTN/SIP leg left in silence. If `listParticipants()` returns 0, there is no surviving leg to drop.

Would the intended SDK fix be to capture the `JobContext`, `RoomServiceClient`, or LiveKit URL/API credentials while `WarmTransferTask` is still inside `onEnter()` / `mergeCalls()`, and then use that captured value inside `onCallerParticipantDisconnected`? Or should `WarmTransferTask` expose an option to disable its built-in post-merge cleanup listener so applications can own teardown without the SDK throwing?

At this point, the most reliable signal we’ve seen is the in-process caller-room `ParticipantDisconnected` event that `WarmTransferTask` already receives. The problem is that its delete path loses job context before it can act

Sorry I didn’t get around to this during the week Dan, and thanks for the PR. Thanks to Brian for jumping on with the fix.

Just to follow up on this thread for public visibility:

The fix is part of the following PR, and will be included in the upcoming release: