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.