acoustic-pharmacist-34178
02/12/2026, 10:30 PMtall-airline-16756
02/12/2026, 10:31 PMtall-airline-16756
02/12/2026, 10:31 PMacoustic-pharmacist-34178
02/12/2026, 10:34 PMexport function getSipBlindTransferTool(options?: unknown) {
const schema = z.object({
transferTo: z
.string()
.describe(
'Destination to transfer to: extension (e.g. 102), E.164 phone number, or full SIP URI (e.g. <sip:102@pbx.exampl>e.com).',
),
department: z
.string()
.describe('The department being transferred to, for example "Sales". Used for confirming the transfer with the caller.'),
playDialtone: z
.boolean()
.optional()
.describe('If true, play a dial tone to the room during transfer.'),
})
return llm.tool({
description: 'Perform a blind (cold) transfer of the current call to the given phone number, extension, or SIP URI.',
parameters: schema,
execute: async ({ transferTo, playDialtone, department }, { ctx }) => {
const jobCtx = getJobContext();
await ctx.waitForPlayout();
console.error(`Playing playout before SIP transfer to ${transferTo} in room ${jobCtx.room.name}`);
ctx.session.generateReply({
instructions: `Let the user know you are transferring them to ${department}`
})
console.error(`Notified caller of transfer to ${department}, now initiating SIP transfer to ${transferTo}`);
try {
const opts =
playDialtone !== undefined ? { playDialtone } : undefined;
await transferSipCall(jobCtx.room, transferTo, opts);
return `Transfer initiated to ${transferTo}.`;
} catch (err) {
const message =
err instanceof Error ? err.message : 'SIP transfer failed.';
return `Transfer could not be completed: ${message}`;
}
},
});
}
This function executes fine, however, the generateReply doesn't worktall-airline-16756
02/12/2026, 10:34 PMctx.session.generateReply() but not awaiting it, so the agent doesn’t wait for speech to be generated or played. Because you immediately start the SIP transfer (which can terminate the session or interrupt audio), the spoken reply may never be produced.
What to change
1. Await the call to generateReply() (it returns a SpeechHandle).
◦ Example: const handle = await ctx.session.generateReply({ instructions: ... })
1. If you want to ensure the message is fully spoken before transferring, wait for playout using the handle:
◦ Example: await handle.waitForPlayout()
Minimal corrected flow
1. await any previous playout: await ctx.waitForPlayout() (you already do this).
2. Generate the reply and wait for it:
◦ const handle = ctx.session.generateReply({ instructions: ... })
◦ await handle.waitForPlayout()
1. Then call transferSipCall(...).
Why this fixes it
• generateReply() does not block; it returns immediately with a SpeechHandle. If you don’t await the handle or call waitForPlayout(), subsequent async operations (like transferring the call) may run first and stop the agent from speaking.
Quick corrected snippet
// Generate and await the spoken reply
const handle = ctx.session.generateReply({
instructions: `Let the user know you are transferring them to ${department}`
});
await handle.waitForPlayout(); // ensures the message finishes playing
// Now initiate transfer
await transferSipCall(jobCtx.room, transferTo, opts);
If you try that and still see the reply cut off, tell me:
• Whether transferSipCall causes the participant/room to be left or session terminated,
• Any errors or logs around the transfer,
• Which TTS voice/streaming setup you’re using (if custom).
I’ll help you iterate from there.
Sources: Agent speech and audio | LiveKit Documentation | How to detect when an agent has finished speaking | LiveKitacoustic-pharmacist-34178
02/13/2026, 12:52 AMexport function getSipBlindTransferTool(options?: unknown) {
const schema = z.object({
transferTo: z
.string()
.describe(
'Destination to transfer to: extension (e.g. 102), E.164 phone number, or full SIP URI (e.g. <sip:102@pbx.exampl>e.com).',
),
department: z
.string()
.describe('The department being transferred to, for example "Sales". Used for confirming the transfer with the caller.'),
playDialtone: z
.boolean()
.optional()
.describe('If true, play a dial tone to the room during transfer.'),
})
return llm.tool({
description: 'Perform a blind (cold) transfer of the current call to the given phone number, extension, or SIP URI.',
parameters: schema,
execute: async ({ transferTo, playDialtone, department }, { ctx }) => {
const jobCtx = getJobContext();
await ctx.waitForPlayout();
ctx.session.generateReply({
instructions: `Let the user know you are transferring them to ${department}`
})
await ctx.waitForPlayout();
try {
const opts =
playDialtone !== undefined ? { playDialtone } : undefined;
await transferSipCall(jobCtx.room, transferTo, opts);
return `Transfer initiated to ${transferTo}.`;
} catch (err) {
const message =
err instanceof Error ? err.message : 'SIP transfer failed.';
return `Transfer could not be completed: ${message}`;
}
},
});
}
It ignores the generateReplytall-airline-16756
02/13/2026, 12:52 AMctx.session.generateReply(...) but still not awaiting the returned SpeechHandle, so the agent doesn’t reliably wait for speech to be generated or played before you start the transfer. Calling await ctx.waitForPlayout() before and after generateReply() doesn’t help unless you actually await the result of generateReply() itself.
What to change
• generateReply() returns a SpeechHandle immediately; you must either await that handle or call waitForPlayout() on it to ensure the speech is produced and finished before continuing.
• If the transfer disconnects the session/participant, the speech can be cut off unless you wait for playout to finish.
Suggested fix (minimal change)
// Generate and await the spoken reply
const handle = ctx.session.generateReply({
instructions: `Let the user know you are transferring them to ${department}`
});
await handle.waitForPlayout(); // ensures the message finishes playing
Full corrected execute block
execute: async ({ transferTo, playDialtone, department }, { ctx }) => {
const jobCtx = getJobContext();
// Ensure any previous playout finishes
await ctx.waitForPlayout();
// Generate reply and wait for it to actually play
const handle = ctx.session.generateReply({
instructions: `Let the user know you are transferring them to ${department}`
});
await handle.waitForPlayout();
try {
const opts = playDialtone !== undefined ? { playDialtone } : undefined;
await transferSipCall(jobCtx.room, transferTo, opts);
return `Transfer initiated to ${transferTo}.`;
} catch (err) {
const message = err instanceof Error ? err.message : 'SIP transfer failed.';
return `Transfer could not be completed: ${message}`;
}
}
If this still appears to be ignored, please tell me:
• Does transferSipCall cause the participant or room to be left or the session to terminate immediately?
• Any logs around the call to generateReply() and the transfer (timestamps help).
• Whether the caller can interrupt speech (user press/key), or if you’re using any custom TTS settings.
With that info I’ll help you confirm whether the speech is starting and whether the transfer interrupts it, and we can add retries or a short delay if needed.acoustic-pharmacist-34178
02/13/2026, 1:00 AM[19:59:11.079] ERROR (344083): exception occurred while executing tool
function: "sipTransfer"
speech_id: "speech_a189d262-234"
error: "Cannot call 'SpeechHandle.waitForPlayout()' from inside the function tool 'sipTransfer'. This creates a circular wait: the speech handle is waiting for the function tool to complete, while the function tool is simultaneously waiting for the speech handle.\nTo wait for the assistant's spoken response prior to running this tool, use RunContext.wait_for_playout() instead."tall-airline-16756
02/13/2026, 1:00 AMsipTransfer tool) is calling waitForPlayout() on a SpeechHandle produced by ctx.session.generateReply() while still running. The platform prevents that because the speech system is itself waiting on the function tool to finish, which would deadlock.
What to do instead
• Do not call SpeechHandle.waitForPlayout() from inside the function tool.
• Use ctx.waitForPlayout() (aka RunContext.wait_for_playout()) before invoking the tool so the assistant finishes speaking first, then call your transfer tool.
• Or split the work into two steps: 1) ask the assistant to generate and play the transfer announcement, wait for playout, then 2) call the sipTransfer tool to do the actual SIP transfer.
How to implement those options
Option A — Wait for playout before calling the tool (preferred if you control the assistant flow)
1. Generate the reply in the assistant turn (not inside the tool):
◦ assistant: generateReply(instructions: "Transferring you to Sales")
1. Await playout in the run context before calling the tool:
◦ call ctx.waitForPlayout() (this is allowed and will wait for the spoken reply to finish)
1. Then call the sipTransfer tool which only performs the transfer (no speech awaits inside it).
Option B — Two-tool sequence (if the assistant must trigger both)
1. Tool 1: announceTransfer — this tool only requests the assistant to generate the spoken reply (or triggers TTS) and returns immediately.
2. On completion of Tool 1, call ctx.waitForPlayout() in run context (outside the transfer tool) to ensure the speech finished.
3. Tool 2: sipTransfer — perform the SIP transfer.
Minimal code pattern (conceptual)
1. Assistant turn:
◦ call ctx.session.generateReply({ instructions: ... }) (or let assistant produce the reply)
1. Outside any function tool, await:
◦ await ctx.waitForPlayout()
1. Now call the sipTransfer tool which only runs the transfer logic and does not call waitForPlayout().
Why this works
• ctx.waitForPlayout() is safe to call from the run context and will wait for the assistant's spoken output to finish.
• Calling SpeechHandle.waitForPlayout() from inside the tool causes a circular dependency because speech generation may depend on the tool finishing—so it is blocked.
Next steps for me to help
• Tell me whether you can reorder the flow so the assistant generates the spoken announcement in the run context before the tool runs.
• If you cannot change the flow, share how your tools are invoked (single tool vs. tool chain). I can suggest exact changes: either split into two tools or modify the assistant turn to play the message first, then invoke the transfer tool.
If you want, paste the call site where you invoke the tool (assistant prompts or orchestration code) and I’ll propose the exact code changes.acoustic-pharmacist-34178
02/13/2026, 1:01 AMtall-airline-16756
02/13/2026, 1:02 AMSpeechHandle.waitForPlayout() from inside a function tool causes a circular wait), so we need to change the orchestration.
What you can do (two safe patterns)
1) Preferred — Generate announcement outside the transfer tool, then call the transfer tool
• Have the assistant (or run context) generate the spoken announcement.
• Wait for playout in the run context using ctx.waitForPlayout() (or await the SpeechHandle outside the tool).
• Then call a function tool that only performs the SIP transfer.
Node.js sketch:
// 1) Generate announcement in run context
const speechHandle = ctx.session.generateReply({
instructions: `Let the user know you are transferring them to ${department}`
});
await speechHandle; // wait for handle to be created
await ctx.waitForPlayout(); // wait until speech finishes playing
// 2) Now call the transfer tool (tool does only transfer)
await callTool('sipTransfer', { transferTo, playDialtone });
Why: ctx.waitForPlayout() is safe to call outside function tools and ensures playback finishes before you start the transfer.
2) If you must start from the assistant/tool pair — split into two tool calls
• Tool A: announceTransfer — triggers generateReply() (but does not await playout; it returns immediately).
• After Tool A returns, in the run context call await ctx.waitForPlayout() to let the announcement finish.
• Tool B: sipTransfer — performs the SIP transfer. This tool must not try to await speech playout.
Why: avoids trying to await speech inside a tool and keeps the transfer separated from playback timing.
Important rules to avoid the error you saw
• Do not call SpeechHandle.waitForPlayout() from inside a function tool — it throws that circular-wait error.
• Do not rely on await ctx.waitForPlayout() inside the tool; it must be used in the run/agent context before invoking the transfer tool.
• The transfer tool should be short and not depend on waiting for the assistant’s speech.
If you can’t change the orchestration
• You can have the tool return a status like "pending_transfer" and have the outer agent detect that and run the wait + transfer sequence.
• Or implement a small workflow/state machine: set a flag, let the assistant produce speech, wait for playout, then invoke the transfer logic.
Next steps I can help with
• I can produce a concrete, end-to-end Node.js or Python example that shows:
◦ assistant generates the announcement,
◦ waiting for playout in run context,
◦ then calling the transfer tool.
• Or, if you paste how you currently invoke the tool (the call site / orchestration code), I’ll modify it to follow one of the patterns above.
Which would you prefer — a full Node.js example, a Python example, or help modifying your existing call site?
Sources: Agents telephony integration | LiveKit Documentation | Agent speech and audio | LiveKit Documentation | Tasks and task groups | LiveKit Documentation