Session Shutdown and Cleanup

Planned behavior: This page defines the agreed shutdown contract for Python handlers using mode: coderunner, both shared sandbox (dedicated: false) and dedicated releases (dedicated: true). The runtime implementation and rollout are pending. Do not rely on the 30-second cleanup period or background-task guarantees on currently deployed runtimes. An SDK upgrade alone will not enable this contract. Dedicated releases built before the runtime update will need to be rebuilt. This page does not define shutdown behavior for mode: relay.

Use the final input StopEvent to finish post-call work, record output, and notify another agent session. VoiceRun will allow a bounded cleanup period after the conversation ends, without allowing cleanup code to restart speech or call control.

Yielding and receiving StopEvent#

StopEvent has two directions:

  • Your handler yields StopEvent: requests that VoiceRun end the call, optionally after closing_speech.
  • Your handler receives StopEvent: notifies your handler that its session is ending and begins its final cleanup invocation.

Yielding StopEvent does not replace the final input StopEvent. Your handler will subsequently receive one input StopEvent during session teardown.

When conversational processing ends#

VoiceRun will stop accepting conversational outputs when it accepts a StopEvent or TransferSessionEvent yielded by your handler or an async generator registered through context.create_task(), or when it detects caller disconnection.

At that point, VoiceRun will:

  • Cancel the handler invocation processing the current input. VoiceRun will not advance the generator that yielded the accepted StopEvent or TransferSessionEvent beyond that yield, whether it is the handler or a generator registered through context.create_task().
  • Cancel unfinished tasks registered through context.create_task(..., interruptible=True).
  • Stop invoking your handler for subsequent inputs, including TextEvent, TimeoutEvent, and ExternalEvent; only the final input StopEvent will be delivered.
  • Reject new conversational outputs from context.create_task() tasks, including tasks registered with interruptible=False.

The unfinished tasks registered through context.create_task(..., interruptible=False) at this point may continue through closing playback and cleanup. Omitting interruptible is equivalent because its default is False. Their permitted outputs are listed below. They may also register further non-interruptible Context tasks during closing playback. Work awaited directly by the interrupted handler, without registration through context.create_task(), is cancelled with that invocation.

Closing speech#

For a yielded StopEvent or TransferSessionEvent:

ArgumentBehavior
clear_speech=FalseFinish previously accepted speech, then play closing_speech if supplied.
clear_speech=TrueCancel previously accepted speech, then play closing_speech if supplied.

VoiceRun will then perform the requested stop or transfer. Synthesis or playback failures use bounded failure handling; they will not create an unlimited playback wait. See Speech Playback Control.

If the caller disconnects during playback, VoiceRun will cancel the remaining speech and proceed to cleanup without waiting for playback acknowledgments from the disconnected caller.

The final input StopEvent#

When the handler's session ends, VoiceRun will invoke your handler once with an input StopEvent, using the session's current Context. This applies to caller disconnection, a handler-yielded StopEvent, and teardown following a TransferSessionEvent or successful MergeSessionEvent.

VoiceRun will not replace this invocation with another queued input or deliver a second final StopEvent if caller disconnection overlaps a handler-yielded StopEvent. This callback will not reopen conversational processing.

This guarantee depends on the handler runtime remaining reachable. A runtime crash or loss of its connection can prevent finalization. VoiceRun will record a user-visible ErrorEvent in the session event log when finalization cannot finish. It will not substitute a newly initialized handler for the original one: a copy of Context cannot restore in-progress Python tasks or handler-local objects.

The 30-second cleanup period#

When VoiceRun begins delivering the final input StopEvent, a 30-second cleanup period starts. The invocation receiving StopEvent and eligible tasks registered through context.create_task() share this deadline. Closing-speech playback occurs before this period and does not consume it.

Cleanup finishes early when the final handler invocation and all eligible Context tasks have completed. Returning from the final handler invocation alone will not end cleanup while eligible Context tasks are still running.

A task's timeout argument still applies. VoiceRun will not restart its timeout or extend the cleanup deadline. For example, a task with five seconds remaining on its own timeout will not receive another 30 seconds when cleanup begins.

At the cleanup deadline, VoiceRun will stop accepting further cleanup outputs and context changes and request cancellation of unfinished cleanup work. Code that ignores cancellation will not extend this deadline. VoiceRun will then synchronize the accepted context, persist eligible fields, and release session resources using bounded platform operations. The 30 seconds limits your cleanup execution; it is not a promise that all storage and infrastructure teardown finishes at exactly 30 seconds.

The cleanup period is a platform setting, initially 30 seconds, shared by the shared sandbox and dedicated releases. It is separate from context.create_task(timeout=...); changing a task timeout will not change the session deadline.

Creating tasks during cleanup#

The handler invocation receiving StopEvent and eligible Context tasks may call context.create_task(..., interruptible=False) during the cleanup period. These newly registered tasks share the same deadline and the same output restrictions. Omitting interruptible is equivalent to setting it to False.

For example, a task registered 25 seconds into cleanup has at most five seconds remaining, even if its own timeout is 30 seconds. Creating another task will not extend cleanup.

VoiceRun will reject context.create_task(..., interruptible=True) during cleanup with an error. After cleanup closes, VoiceRun will reject all new context.create_task() registrations with an error; it will not silently accept a task that cannot run.

The final StopEvent invocation and eligible Context tasks may await child work using asyncio.gather() or asyncio.TaskGroup. While that parent invocation or registered task remains running, its child work may produce the same permitted outputs and register non-interruptible Context tasks. The same cleanup deadline applies.

Use Context background tasks, registered with context.create_task(), for background work that should participate in cleanup. Detached tasks created directly with asyncio.create_task() are not registered with Context and have no cleanup completion guarantee. After their parent invocation or registered task returns, their emitted events and new context.create_task() registrations are rejected, even if other cleanup work is still running.

That event rejection does not isolate mutations of the shared Python Context. A detached task can still change it through context.set_output(), context.set_data(), or direct dictionary writes. If those mutations occur before the final snapshot, they may appear in persisted outputData or tags even though the task's emitted events were rejected. VoiceRun does not provide mutation isolation for detached tasks.

Permitted cleanup outputs#

These rules apply from acceptance of StopEvent or TransferSessionEvent, or detection of caller disconnection, until cleanup closes. This includes closing playback, eligible Context tasks, the final handler invocation, and the awaited child work described above.

Output or operationBehavior during cleanup
context.set_output(payload) / ContextPatchEventAccept updates until cleanup closes.
context.set_data(key, value) / ContextUpdateEventInclude eligible changes in the final synchronized context.
LogEvent, ErrorEvent, DebugEventAccept for diagnostics without restarting conversational processing.
ExternalEvent(..., after_speech=False)Accept the send before cleanup closes; finish the delivery attempt using a bounded operation and report send failures.
ExternalEvent(..., after_speech=True)Reject; cleanup will not wait for another playback boundary.
HandlerEndEventRuntime completion bookkeeping; your handler does not need to yield it. Completion of the final invocation does not imply completion of Context tasks.
All other yielded event typesReject during cleanup.

VoiceRun will discard rejected yielded events without raising an exception into your generator. In contrast, an ineligible context.create_task() call raises RuntimeError immediately.

Rejected event types include TextToSpeechEvent, AudioEvent, InterruptEvent, UpdateAudioSettingsEvent, InputAllowedEvent, StopEvent, TransferSessionEvent, StartSessionEvent, and MergeSessionEvent.

An ExternalEvent sends a message to another agent session. For example, the handler receiving StopEvent can notify the other leg of a transfer that this handler session ended. StopEvent alone does not identify whether the caller disconnected or a merge ended the handler connection. Sending the event does not guarantee the destination handler processed it. An ExternalEvent addressed to the session being finalized will not cause another handler invocation.

VoiceRun will report cleanup expiry, incomplete finalization, failed notification delivery, and failed final persistence with user-visible ErrorEvent messages in the session event log. These messages describe the effect on your session; platform diagnostics, internal addresses, and internal exception details will not appear in those messages. A failed notification will not prevent VoiceRun from accepting the final context snapshot.

Direct Python operations, such as an awaited CRM HTTP request, can run within the cleanup deadline. Cancelling the Python task does not undo an HTTP request already accepted by another service.

Context updates and final persistence#

Updates from both the final handler invocation and eligible Context tasks count toward the final synchronized context. VoiceRun will not discard an otherwise eligible update simply because the telephone conversation has ended.

  • context.set_output(payload) supplies the session's persisted outputData.
  • context.set_data("tags", tags) supplies the session's persisted tags.
  • Other context.set_data() values remain session context; this contract does not make every context key a permanently stored session field.

context.set_output(payload) replaces the output payload; it does not merge it. If multiple tasks update the payload, coordinate those writes in your code. See Context output data.

VoiceRun will take the final snapshot after the final handler invocation and eligible Context tasks finish, or after the cleanup deadline closes admission of updates. Updates received after that boundary will not change the persisted result. If the runtime is lost before final synchronization, VoiceRun cannot recover changes that never reached the engine.

MergeSessionEvent and session shutdown#

Yielding MergeSessionEvent(session_id=...) requests that the telephony provider bridge two calls or place them in a conference. It does not itself confirm success or begin the cleanup period.

Depending on the telephony provider and routing, a successful merge can end one or both VoiceRun handler sessions while the telephone participants remain connected. Do not depend on a particular number of handler sessions ending. When a merge ends a session's connection to its VoiceRun handler, that handler will receive the final input StopEvent and the same cleanup period described above. Each affected session is finalized separately.

Receiving StopEvent after a merge does not mean the two telephone participants disconnected from each other. Do not treat every final StopEvent as evidence that a merge failed.

If the merge request fails and the agent connections remain active, yielding MergeSessionEvent alone will not finalize either session. The current event API does not provide a reliable handler-visible merge result; do not interpret yielding the event as a success acknowledgment.

MergeSessionEvent does not wait for preceding speech and has no after_speech option. Coordinate playback completion before yielding it, as described in Speech Playback Control.

The same distinction applies to TransferSessionEvent(agent_id=...): the source handler receives its final input StopEvent when its own session ends. The target agent's session has its own lifecycle. Yielding the transfer request does not mean both handlers finish together.

eventscontextshutdowntransfers