Speech Playback Control
Use playback options to replace outdated speech, start hold music after an announcement, notify another agent when speech finishes, or deliver a closing message before ending or transferring a session. Handlers yield events and return; they do not need speech IDs, duration estimates, or sleeps.
These options require primfunctions 0.1.106 or later and the corresponding
engine and sandbox runtime rollout. Rebuild agent releases after upgrading the
SDK. Installing the SDK alone does not enable playback ordering on an older engine.
Cancel or replace speech#
InterruptEvent() cancels active synthesis and current and queued speech,
including audio already buffered by the provider. The handler can keep yielding:
from primfunctions.events import InterruptEvent, TextToSpeechEvent yield InterruptEvent() yield TextToSpeechEvent(text="Let me check that again.")
For an urgent update, combine cancellation and enqueueing atomically with
replace=True:
yield TextToSpeechEvent( text="Your specialist is ready. Connecting you now.", replace=True, interruptible=False, )
interruptible=False protects speech from caller barge-in. Explicit interruption
and replacement can still cancel it. An external input does not automatically
clear old speech; its handler can choose replacement. Cancellation leaves the
configured background mixer enabled, so mute hold music explicitly when needed.
Start hold music after an intro#
from primfunctions.events import TextToSpeechEvent, UpdateAudioSettingsEvent # HOLD_MUSIC_URL is the URL of your audio asset. yield TextToSpeechEvent( text="Please hold while I connect you with a specialist.", interruptible=False, ) yield UpdateAudioSettingsEvent( background_track_url=HOLD_MUSIC_URL, background_track_volume=0.2, background_track_loop=True, after_speech=True, )
The engine captures the preceding speech boundary when it accepts the update.
Later speech does not extend that wait. If no speech is outstanding and there is
no failed preceding boundary, the update applies immediately. If preceding speech
failed or timed out, the deferred update is dropped instead; re-issue it after new
speech, or send it without after_speech to apply it immediately.
A later mixer update supersedes an older deferred update,
so a delayed music start cannot undo a later mute:
yield UpdateAudioSettingsEvent( background_track_volume=0, background_track_loop=False, )
If the intro is cancelled (including caller barge-in), fails synthesis, or times out, the deferred music update is dropped. Track scheduling separately from actual start; a handler needing hold music must re-arm it against a fresh successful boundary. Speech protection starts when input gating takes effect, so there is a window before it becomes active.
Notify another agent after an announcement#
after_speech=True on an outgoing ExternalEvent delays sending the notification.
The recipient still receives an ordinary external input event.
from primfunctions.events import ExternalEvent, TextToSpeechEvent, UpdateAudioSettingsEvent yield UpdateAudioSettingsEvent(background_track_volume=0, background_track_loop=False) yield TextToSpeechEvent( text="Alright, connecting you now.", replace=True, interruptible=False, ) yield ExternalEvent( agent_id=outbound_agent_id, session_id=outbound_session_id, name="caller_ready", data={}, after_speech=True, )
The handler can return immediately. An accepted deferred notification survives handler cancellation, but stop/transfer acceptance, cancellation, or replacement of the awaited speech cancels a pending notification. Synthesis failure or an abnormal acknowledgement timeout also suppresses it. Multiple notifications on one boundary run in acceptance order.
Caller barge-in that reaches the engine also cancels pending deferred actions.
interruptible=False protects speech once input gating takes effect, not from the
instant the event is accepted. A cancelled, failed or timed-out boundary drops the
notification; it is never delivered late as a success.
Two-leg transfer handshake#
Each leg must confirm its own announcement before reporting readiness to the
other leg. To make the cross-leg notification retryable, first send a deferred
notification to your own session, record its arrival in Context, and then
send an immediate notification to the other leg.
For example, the caller leg can schedule its announcement and local confirmation:
from primfunctions.events import ExternalEvent, TextToSpeechEvent, TimeoutEvent attempt = context.get_data("caller_playback_attempt", 0) + 1 context.set_data("caller_playback_attempt", attempt) context.set_data("caller_playback_confirmed", False) yield TextToSpeechEvent( text="Alright, connecting you now.", replace=True, interruptible=False, ) yield ExternalEvent( agent_id=context.agent_id, session_id=context.session_id, name="caller_playback_ready", data={"attempt": attempt}, after_speech=True, )
In the handler's input branches, record only confirmation for the current attempt.
After checking your readiness deadline, use the same immediate notification for
the first confirmation, timeout retries, and duplicate transfer_connected
requests from the buyer leg:
if isinstance(event, ExternalEvent): name = event.data.get("name") if name == "caller_playback_ready": attempt = event.data.get("data", {}).get("attempt") if attempt != context.get_data("caller_playback_attempt"): return # Ignore confirmation from an obsolete announcement. context.set_data("caller_playback_confirmed", True) elif name != "transfer_connected": return # Other event names need their own handler branches. elif not isinstance(event, TimeoutEvent): return if context.get_data("caller_playback_confirmed", False): yield ExternalEvent( agent_id=outbound_agent_id, session_id=outbound_session_id, name="caller_ready", data={}, )
These are the announcement and readiness branches, not a complete transfer handler.
Ignore duplicate readiness requests while your announcement is pending; once it
is confirmed, reply without replaying the speech. The buyer leg uses the same
pattern for its own announcement, then sends immediate transfer_connected
notifications. It can issue MergeSessionEvent once both legs are ready, once only.
A self-addressed ExternalEvent arrives as ordinary external input: it starts a
new turn, cancels the running handler invocation, and resets the consecutive
silence-timeout count. This pattern only works before stop/transfer acceptance;
an event addressed to a session being finalized will not invoke its handler again.
Local confirmation can itself be lost through interruption, a stale turn, synthesis
failure, or playback timeout. Re-announce with a new attempt number and ignore old
confirmations, or abandon the attempt at the readiness deadline and notify the
other leg. Elapsed time alone must never count as successful playback.
MergeSessionEvent has no after_speech option, and a handler remaining connected
after the merge request is not proof that the merge failed.
Drive the readiness deadline from input/timeout ticks using an elapsed-time deadline
stored in Context. The engine currently ends a session after 20 consecutive
silence timeouts. Caller speech and ordinary incoming ExternalEvents reset that
counter; background events do not follow that reset path. Handle readiness failure
before the cap is reached, but do not use TimeoutEvent.count as your only deadline
because notification traffic can reset it.
Notify the other leg if the handshake ends before readiness completes. The final
StopEvent says the handler session ended; it does not identify the cause. An
immediate notification can also be rejected if its originating turn became stale,
so yielding it before stop is not a delivery acknowledgment.
End or transfer after closing speech#
By default, a terminal event drains preceding accepted speech, plays its closing message, then ends or transfers the session:
from primfunctions.events import StopEvent, TextToSpeechEvent yield TextToSpeechEvent(text="Here are the details we discussed.") yield StopEvent(closing_speech="Thank you for calling. Have a wonderful day.")
There is no five-second deadline starting when the terminal event is accepted.
A plain StopEvent() also waits for preceding speech. Use clear_speech=True when that
preceding speech is obsolete:
yield StopEvent( closing_speech="I'm sorry, the specialist is unavailable. Please try again later.", clear_speech=True, )
Transfers use the same closing sequence:
from primfunctions.events import TransferSessionEvent yield TransferSessionEvent( phone_number="+15551234567", # Replace with your destination. closing_speech="I'll connect you to the support desk now.", clear_speech=True, )
Ordering and the point of no return#
Within a handler, context updates made before yielding stop/transfer are saved
first. Yielding stop/transfer commits termination and blocks new application
inputs for that session. Previously queued speech drains unless explicitly
cleared. Detached asyncio work and deferred after_speech=True actions are not guaranteed to
finish.
For example:
context.set_output({"outcome": "unavailable"}) yield StopEvent( closing_speech="Sorry, we could not connect you. Please try again later.", clear_speech=True, )
Your output result is saved before stop. clear_speech=True clears obsolete speech,
plays the closing message, then hangs up; it does not discard the output result.
With clear_speech=True and no closing message, the engine clears speech and hangs up.
Without clearing preceding speech, it finishes preceding speech before the closing message.
Transfer follows the same rules.
The runtime does not continue the handler past its stop/transfer yield. Caller interruption can cancel a handler before it yields a terminal event; once it has yielded that event, the request cannot be cancelled by caller interruption. The engine protects the remaining closing sequence from caller barge-in. Other sessions in the same server remain active.
Pending after_speech=True notifications and mixer updates are cancelled when
stop/transfer is accepted—even if you yielded them first. Do not append stop to a
deferred notification and expect delivery. For an immediate notification, yield
ExternalEvent(...) without after_speech=True; yielding it is not an
acknowledgment that the destination processed it.
Playback completion and application cleanup have separate deadlines. See
Session Shutdown and Cleanup for the planned behavior of
context.create_task(..., interruptible=False) after conversational processing ends.
Internal synthesis, playback acknowledgements, and disconnect handling continue while the engine finishes closing. Transport or synthesis failure can still take the documented failure path below.
Final session-end callback#
Yielding StopEvent requests termination; receiving the final input StopEvent
invokes your handler for cleanup. The standalone Session Shutdown and
Cleanup page defines the planned contract for shared sandbox and dedicated Python handlers: a shared 30-second cleanup period, permitted Context tasks and
outputs, final persistence, and teardown after MergeSessionEvent. Check that
page's rollout notice before relying on these guarantees.
Completion and failure behavior#
Playback completion means the configured transport boundary completed; it does not prove a person heard the message. Twilio/Telnyx use named marks. Genesys uses eligible playback notifications. Infobip uses an explicitly labelled estimate based on audio samples and send progress. Local/mock mark echoes validate event ordering, not real phone playback. VoiceRun SBC consumption-based guarantees additionally require the SBC release with compatible confirmed-playback module and adapter. Its negotiation is internal to the SBC; the engine sends ordinary media, marks, and clear and receives unchanged mark echoes. Cleared marks cannot trigger successful-speech actions. The wire does not distinguish old SBC estimates from confirmed playback, so the guarantee depends on the deployed SBC version. Its explicit legacy mode ends the call on the first clear and is not suitable for interactive calls.
Missing acknowledgements and synthesis stalls have bounded watchdogs. On a genuine
terminal failure, the engine logs that closing playback was not confirmed and
executes its stop/transfer fallback once. Success-dependent notifications such as
caller_ready never fire through that fallback.
All new options default to False, preserving ordinary queued speech and immediate
mixer updates/notifications when omitted.
