Skip to content

feat(client): add setOnHold to pause and resume a voice conversation - #974

Open
chinmayv095 wants to merge 3 commits into
elevenlabs:mainfrom
chinmayv095:feat/conversation-on-hold
Open

chinmayv095 wants to merge 3 commits into
elevenlabs:mainfrom
chinmayv095:feat/conversation-on-hold

Conversation

@chinmayv095

Copy link
Copy Markdown
Contributor

Fixes #334.

setMicMuted stops the user being heard and setVolume({ volume: 0 }) stops the agent being heard, but neither of them stops the agent. The utterance already in flight plays to completion, the audio behind it keeps queueing up, and the agent keeps taking turns into a page nobody is looking at. That is what the presentation app in the issue ran into, and the workaround posted there needs a forked SDK because the one piece that actually stops local playback, output.interrupt(), is not reachable from a Conversation.

This adds setOnHold(isOnHold) and isOnHold() to voice conversations. The naming follows the "on hold" framing @kraenhansen suggested on the issue and @hardiksondagar agreed with, and the boolean setter matches the shape of setMicMuted rather than introducing a pause()/resume() pair.

What a hold does

  • Stops the agent, rather than muting it. output.interrupt() drops the current utterance, and the output is then silenced so audio that keeps arriving is not played.
  • Mutes the microphone, so nothing said near the device reaches the agent or starts a turn.
  • Sends user_activity every second, which is how the agent is told the user is busy elsewhere. It is what keeps the agent from speaking up on its own during a long hold, and what stops the session idling out while it is held.
  • Keeps the connection, the conversation id and the agent's context. The whole point of the issue is that reconnecting every 30 seconds is slower and more expensive than holding.

Releasing the hold restores the microphone and the volume the caller had asked for, and flushes audio that arrived during the hold so the agent does not resume from the middle of a sentence nobody heard.

Two ordering details worth reading in the diff

interrupt() restores the output's own stored volume once its fade completes, so the value playback should come back to has to be set before that flush fires. Hold therefore sets the zero after interrupt(), and release sets the caller's volume before it. Both directions are pinned in the tests by invocation order, because getting this backwards silently un-mutes a held conversation 50ms later.

playAudio already cancels a pending interrupt timeout and posts clearInterrupted itself, so a chunk that arrives during either fade flushes the queue at that moment instead of undoing the hold. That is why a 50ms fade is enough here, instead of the two second default interrupt() uses for a server-side interruption, which is far too slow for a caller that asked for the agent to stop now.

Calls made during a hold

setVolume and setMicMuted are remembered and applied when the hold is released, rather than taking effect immediately. A volume change bringing a held agent back, or an unrelated unmute reopening the microphone mid-hold, would both defeat the feature. The mic state to return to is read from the input controller at the moment the hold starts, so a hold released without any intervening call leaves the microphone exactly as it found it.

onModeChange no longer reports speaking for audio that arrives during a hold, since the user cannot hear it. onAudio is deliberately left alone: it is the wire-level callback, so consumers doing their own playback or transcript work still see everything that arrives.

Transports

On WebSocket the local queue is what plays, so interrupt() is what stops the agent. On WebRTC playback belongs to LiveKit and interrupt() is a documented no-op there, so the local silence comes from the output volume path instead. The microphone mute and the user_activity suppression are identical on both, and on both the agent is inaudible for the whole hold and stops taking new turns.

Scope

@elevenlabs/client only, matching the issue's label. Nothing in @elevenlabs/react is touched. A text conversation has no audio to stop and no microphone to mute, so setOnHold throws there like setVolume and setMicMuted already do, and isOnHold() is always false.

Verification

packages/client/src/VoiceConversation.test.ts is new and covers the hold behaviour in 10 tests: muting and silencing on hold, restoring the volume and the microphone on release, a microphone that was already muted staying muted, deferred setVolume/setMicMuted, a repeated setOnHold being a no-op, the user_activity interval starting and stopping, the interval being cleared by endSession, no interval for a session that has already ended, and the mode reporting above. All 10 fail against main without this change. The text-conversation assertion sits next to the existing setVolume one in index.test.ts.

  • turbo check-types lint test --filter=@elevenlabs/client: clean, 250 tests passing.
  • Monorepo turbo check-types lint test: green except convai-widget-core's DismissButton.test.ts, which fails identically on a clean main checkout (browser matcher timeout, unrelated to this change).

Changeset included as a minor bump for @elevenlabs/client.

Muting the microphone and zeroing the volume stop the user and the agent
being heard, but neither of them stops the agent: the utterance already
in flight plays to completion, the audio behind it keeps queueing, and
the agent keeps taking turns into a page nobody is looking at. The one
piece that stops local playback, output.interrupt(), is not reachable
from a Conversation, so apps that hand control back and forth between
the agent and something else have to fork the SDK to get at it.

setOnHold(true) drops the current utterance, silences audio that keeps
arriving, mutes the microphone so nothing nearby starts a turn, and
sends user_activity periodically so the agent does not speak up on its
own or let the session idle out. Releasing the hold restores the
microphone and the volume the caller asked for, and flushes audio that
arrived meanwhile so playback resumes from what the agent says next.
setVolume and setMicMuted calls made during a hold are applied when it
is released rather than reopening a held conversation.

Fixes elevenlabs#334
@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

PR Summary

Medium Risk
Touches live voice I/O, playback interrupt ordering, and mode callbacks; mistakes could leak audio during a hold or mis-report agent state, but behavior is heavily tested and scoped to the client SDK.

Overview
Adds setOnHold and isOnHold on @elevenlabs/client voice conversations so apps can pause agent interaction without tearing down the WebRTC/WebSocket session (fixes handoff between the agent and other page UI).

On hold: interrupts in-flight agent audio with a short fade, forces output volume to zero, mutes the mic, and sends user_activity every second so the agent does not take turns or idle out. onModeChange no longer reports speaking while held (wire-level onAudio is unchanged).

On release: restores the caller’s volume and mic state (including setVolume / setMicMuted deferred during the hold), flushes audio queued during the hold, and carefully re-emits speaking mode for WebRTC vs local playback paths.

Text conversations throw on setOnHold (like other audio APIs); isOnHold() stays false. Shipped as a minor changeset with a large new VoiceConversation.test.ts suite.

Reviewed by Cursor Bugbot for commit 5d446c1. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread packages/client/src/VoiceConversation.ts Outdated
handleAudio was the only path gated on the hold, but it is not the only
path that reports the agent as speaking. On the WebSocket transport the
output worklet posts its own progress events, and audio that arrives
during a hold is still fed to it at volume zero, so it reports speaking
for buffers nobody can hear. On WebRTC the mode comes from the room's
active speaker instead, which the hold never touches at all.

Gate the mode in updateMode, where all three sources arrive, rather than
at each of them. A transport that plays the agent's track itself is
never interrupted by the hold, so the mode it reported while held is
remembered and reported when the hold is released; local playback is
flushed on release and the worklet reports the drain itself, so there is
nothing to restore there.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 12e4d60. Configure here.

Comment thread packages/client/src/VoiceConversation.ts
…ld starts

The hold reports listening itself, and that cleared the record of what the
agent was doing, so a live track that was mid-utterance when the hold began
came back audible with the mode stuck on listening.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Pause/Resume Conversation Method

1 participant