Warm transfer doesn't proactively speak when the human answers

I’m using WarmTransferTask in Node SDK v1.6.0.

When the agent performs a warm transfer, it calls the human’s number. However, when the human answers, the agent doesn’t proactively explain what the call is about. It remains silent until the human speaks, and only then states the reason for the call.

That’s intended, not a bug. In the Python source the line right after the dial resolves is literally:

self._human_agent_sess = dial_human_agent_task.result()
# let the human speak first

Node does the same dialHumanAgent() returns the session after createSipParticipant({ waitUntilAnswered: true }), and nothing calls generateReply() afterwards (warm_transfer.ts). The template does say “start by giving them a summary”, but that only lands once a turn is triggered, and none is on answer.

This behavior seems confusing. What’s the rationale behind this decision?

Two things in the same class point at why: voicemail_detected is documented as “Use this tool AFTER you hear the voicemail greeting”, and dtmf targets destinations that “play a greeting before accepting input”. Both require the agent to be listening at the moment the call connects, so a proactive greeting would need to be opt-in rather than the default.

Worth filing on agents-js a maintainer can confirm the intent, and an opt-in flag would cover your case without breaking voicemail detection.

Thanks for the response. Yes an opt-in option would be great for cases where you want to proactively call someone and start a conversation. It would be strange for someone to receive a call and then hear silence on the other end without knowing what the call is about.

I opened a GitHub issue in the agent-js repo. It would also be great to get a response from a LiveKit maintainer or team member in this thread.

If this were Python, you could probably subclass the WarmTransferTask and override on_enter but I can’t think of a way to do that for ts other than copy / modify the open source task yourself.

It is a difficult problem to solve, since how do you know someone is ready to listen. I like the idea of adding an opt-in option like you describe.

Depending on the use case, you can assume they’re ready to listen when they answer the call. You could test the waters with a quick “Hi” or “Hello” before getting into it, just like on a real call between humans.

Copying/modifying the open source task would be a proper short term solution, but I’d end up having to maintain a separate copy of the internal implementation, which is less ideal.

Could the LiveKit team implement this opt-in option? It’s probably a quick and easy add.

The engineering team ultimately have that decision, and although an issue will work, it might be quicker long-term to raise a PR. I don’t believe this has been previously considered, probably one of those things that is only obvious in hindsight (unless I’m missing something) :slight_smile:

I imagine it would take some time to get acquainted with your codebase before I could submit an optimal PR, even if the change seems simple to those already familiar with it.

Would it be possible to check with the dev team and see what they think?

I find it interesting that this has never been considered before, maybe I’m the one missing something.

I’m not sure I understand why voicemail and dtmf take priority over proactively speaking during a warm transfer that is supposed to reach a human directly (even though they could remain unchanged with the opt-in option mentioned earlier).

Currently, during a warm transfer, the AI agent calls a human and remains silent. If the human does not start speaking early enough, the agent may end up telling the original caller that the human could not be reached. This creates a race condition in which the human must quickly realize that they are expected to speak first.

Also, some people consistently wait for the caller to speak first before saying anything.

A clear use case for a proactive warm transfer would be an AI receptionist escalating a call to a human and beginning by explaining what the call is about.

Please let me know if I’m misunderstanding something fundamental.

Hi Royi,

I went through the source in both SDKs to pin down exactly what’s happening. Short version: you’re reading the behavior correctly, it’s the same in Node and Python, and it turns out it was considered (as also mentioned by @abidullahcs.uk above) — the silence is a deliberate default, not an oversight. See below for a clean way to get the behavior today without forking anything internal.

1. The silence is intentional

The Node warm-transfer task is a port of the Python one, and the Python source is explicit about the choice. Right after the dial to the human resolves:

self._human_agent_sess = dial_human_agent_task.result()
# let the human speak first

agents/warm_transfer.py#L196-L197

The Node port kept that: the briefing agent is constructed as a plain Agent with no onEnter override, and no code path ever calls generateReply() after the human answers → agents-js/warm_transfer.ts#L392-L402

Your guess about the rationale is right: voicemail_detected works by listening to the far end’s greeting, and IVR/dtmf navigation assumes the destination plays prompts before accepting input. A proactive greeting talks over both. For a transfer that’s known to reach a human directly, that tradeoff is backwards.

2. Why “greet in onEnter” wouldn’t fix it — and where the fix actually goes

The briefing session is started before createSipParticipant(..., waitUntilAnswered: true) resolves, i.e. the session is already live while the phone is still ringing (JS ordering, Python ordering). So even a subclass with an on-enter greeting would speak during ringing, not on answer.

The good news: because the dial uses waitUntilAnswered: true, the moment it resolves is the answered moment — and any dtmf has already been sent as part of the SIP participant creation. So the whole feature is one line at the right spot.

JS — right where the dial wins the race (warm_transfer.ts#L593):

transferAgentSession = result.session;
transferAgentSession.generateReply(); // human just answered — brief them proactively

Python — right after warm_transfer.py#L347:

human_agent_sess.generate_reply()

An opt-in option (speakFirst / greetOnAnswer, default false) wrapping that line would leave voicemail and DTMF behavior untouched for everyone else. The Python task also still lives under beta.workflows, so the API surface is explicitly still settling — a reasonable time to add it.

3. Doing it today with the SDK

Two options, neither of which touches SDK internals — the task is built entirely on public APIs (AccessToken, Room, AgentSession, SipClient, moveParticipant):

Option A — vendor the one file. The task is a single self-contained file. Copy warm_transfer.ts into your project, add the one line above at L593, and pin it to your SDK version. It’s less of a “fork of the internal implementation” than it sounds — you keep all the hard parts (hold audio, caller-hangup cancellation mid-ring, room teardown) for free.

Option B — own the flow directly. The core is small if you don’t need all the edge-case handling:

import { Room } from '@livekit/rtc-node';
import { AccessToken, RoomServiceClient, SipClient } from 'livekit-server-sdk';
import { llm, voice } from '@livekit/agents';

// inside a tool on your caller-facing agent:
const transferRoomName = `${ctx.room.name}-human-agent`;
const identity = 'human-agent-sip';

// 1. join a private briefing room (mint a token with roomJoin on transferRoomName)
const room = new Room();
await room.connect(wsUrl, jwt);

// 2. start the briefing session — it stays silent while the phone rings
const session = new voice.AgentSession({ llm, stt, tts, vad });
await session.start({
  agent: new voice.Agent({
    instructions: summaryInstructions, // include the caller conversation history
    tools: [connectToCallerTool],
  }),
  room,
  inputOptions: {
    participantIdentity: identity,
    closeOnDisconnect: true,
    deleteRoomOnClose: true,
  },
});

// 3. dial the human — resolves when they answer (DTMF, if any, is sent by the SIP service)
const sip = new SipClient(wsUrl, apiKey, apiSecret);
await sip.createSipParticipant(trunkId, supervisorNumber, transferRoomName, {
  participantIdentity: identity,
  waitUntilAnswered: true,
});

// 4. the line the built-in task doesn't have: speak first
session.generateReply();

// 5. connectToCallerTool → merge the human into the caller room
await new RoomServiceClient(wsUrl, apiKey, apiSecret)
  .moveParticipant(transferRoomName, identity, ctx.room.name);

Step 3→4 is the answer to the race condition you described: waitUntilAnswered: true gives you a deterministic “human picked up” signal, so the agent greets immediately on answer instead of both sides waiting each other out.

Give that a try and see if it solves your issue.

@royibernthal

Can you tell me more about your use case? For outbound calls that I make as a human. I almost always expect the person I am calling to answer with “Hello?” or something along those lines.

So I want to understand more clearly why your use case deviates from that.

I appreciate the thorough response.

“For a transfer that’s known to reach a human directly, that tradeoff is backwards.” - I agree, hence I’m arguing we should probably be able to choose the optimal tradeoff based on who we expect the transfer to reach.

Option A sounds reasonable, I’ll give it a try if you’ll conclude it wouldn’t make sense to add that line along with a flag to the livekit repo.

I’d say it depends on the persona, whether they’re expecting calls from that number, and what kind of calls they expect to receive from it.

In my case, an AI receptionist escalates calls to business owners when needed. The business owners expect the AI receptionist to call them from that number, and once they answer they expect it to immediately explain what the call is about.

I’ve watched several business owners literally just waiting for the AI to speak first after answering the call, not realizing it expects them to speak first, and either giving up and hanging up after a few seconds of awkward silence, or timing out.

It’d be worth adding that AI outbound calls we’re getting from medical institutions in my country (Israel) immediately start speaking and telling us what the call is about before we say anything. It’s probably unrelated to warm transfers, but it might help convey the expectations people have when receiving calls from numbers they know are AI.

Thanks. I’ve passed your feature request on to the Agents team.