Skip to main content

Overview

The Session object on ctx.session is your interface to the live call. You can speak text, interrupt the agent’s current utterance, transfer the caller, pause/resume recording, and end the call - all from within your entrypoint function.
async def handle_call(ctx: CallContext) -> None:
    session = ctx.session  # Session object
    await session.say("Hello!")
    await session.run()    # main event loop

Speaking Text

say(text)

Immediately speak a string via the configured TTS provider:
await ctx.session.say("Thank you for your patience, let me look into that.")
say() dispatches the utterance and returns immediately. The TTS synthesis and playback happen asynchronously on the call. Use run() to keep the call alive and process responses.

set_filler(text)

Set a filler phrase hint. The bridge plays it during brief processing silences to keep the call feeling natural:
await ctx.session.set_filler("One moment please...")

Interrupting the Agent

interrupt()

Stop the current TTS utterance immediately:
@ctx.session.on("user_turn")
async def on_user_turn(text: str) -> None:
    if "stop" in text.lower():
        await ctx.session.interrupt()

Transferring Calls

Transfer to a Human Queue

Cold-transfer the caller to a human agent queue:
await ctx.session.transfer_to_human(queue="tier-2-support")
The queue value is the queue identifier in your telephony setup.

Transfer to Another Agent

Cold-transfer to a different Unpod AI agent:
await ctx.session.transfer_to_agent(agent_id="agt_billing_agent")
A cold transfer drops your current session immediately when the transfer is initiated. The caller is connected directly to the target without a warm handoff. Use hooks (e.g. call_end) to log the reason.

Ending a Call

end(reason)

Gracefully hang up the call:
await ctx.session.end(reason="completed")
Common reason values:
ReasonWhen to use
"completed"Normal call resolution
"no_response"User went silent for too long
"error"Unrecoverable error state
"transferred"After a transfer (for logging)
"max_duration"Hard duration cap reached

Recording Control

Pause Recording

Pause the call recording, e.g. before collecting sensitive information:
@ctx.session.on("user_turn")
async def on_user_turn(text: str) -> None:
    if "card number" in text.lower():
        await ctx.session.recording.pause(reason="PII")
        await ctx.session.say(
            "I'll collect your card number now. "
            "This part of the call is not recorded."
        )

Resume Recording

await ctx.session.recording.resume()
await ctx.session.say("Recording has resumed. How else can I help?")
Recording pause/resume requires the agent to have recording=True set at creation. Calls from agents with recording disabled ignore these controls.

Per-Call Custom Data

session.data is a plain dict you can use to store anything scoped to the current call:
async def handle_call(ctx: CallContext) -> None:
    # Pull caller context from CRM at call start
    ctx.session.data["customer"] = await crm.lookup(ctx.user_number)
    ctx.session.data["start_time"] = time.monotonic()

    await ctx.session.run()

@ctx.session.on("call_end")
async def on_end(final_state: str) -> None:
    duration = time.monotonic() - ctx.session.data["start_time"]
    customer = ctx.session.data["customer"]
    await analytics.log(customer["id"], duration, final_state)

The Main Loop - run()

session.run() is the event loop that keeps the call alive. It:
  1. Reads events from the bridge (user speech, interruptions, errors)
  2. Fires registered hooks
  3. Routes user text to the dialog adapter if one is set
  4. Streams the adapter’s reply back via TTS
async def handle_call(ctx: CallContext) -> None:
    # Set up dialog adapter
    ctx.session.dialog_machine = my_dialog_machine

    # Optionally inject a greeting before the loop
    await ctx.session.say("Hi, I'm Alex. How can I help?")

    # Hand off to the loop
    await ctx.session.run()  # blocks until call ends
After run() returns, the call is over. Any code after the await session.run() line runs as a post-call cleanup hook.

Live Metrics

Access per-call metrics inside or after run():
async def handle_call(ctx: CallContext) -> None:
    await ctx.session.run()

    # After call ends, inspect metrics
    m = ctx.session.metrics.live()
    print(f"Turns: {m.turn_count}")
    print(f"Avg LLM latency: {m.avg_llm_ms:.0f}ms")

CallMetrics fields

FieldTypeDescription
turn_countintNumber of dialog turns
avg_stt_msfloatAverage STT latency per turn
avg_llm_msfloatAverage LLM latency per turn
avg_tts_msfloatAverage TTS latency per turn
tokens_inintTotal input tokens consumed
tokens_outintTotal output tokens generated

Session API Reference

MethodSignatureDescription
sayasync (text: str) → NoneSpeak text via TTS
interruptasync () → NoneStop current utterance
set_fillerasync (text: str) → NoneSet filler phrase
transfer_to_humanasync (queue: str) → NoneCold transfer to human
transfer_to_agentasync (agent_id: str) → NoneCold transfer to agent
endasync (reason: str) → NoneEnd the call
runasync () → NoneMain event loop
on(event: str) → decoratorRegister hook
recording.pauseasync (reason: str) → NonePause recording
recording.resumeasync () → NoneResume recording
metricsproperty → MetricsTrackerPer-call metrics
dialog_machineproperty (get/set)Dialog adapter
datadict[str, Any]Per-call scratch space

Out-of-Band Session Control

The controls above run inside your entrypoint, on ctx.session. You can also act on a live session from outside it - e.g. from your backend or an ops tool
  • using the management SDK. These target the session by ID and are useful for supervisor handoffs, bulk control, or reacting to events elsewhere in your stack.
from unpod import AsyncClient

async with AsyncClient(api_key="sk-...") as client:
    # End a live session
    await client.sessions.end(session_id)

    # Transfer it (supports a warm handoff, unlike the cold in-call transfer)
    await client.sessions.transfer(
        session_id,
        to_type="sip",
        to_config={"number": "+15551230000"},
        mode="warm",
        warm_handoff_ms=4000,
    )

    # Merge other sessions into a primary one (e.g. conference a supervisor in)
    await client.sessions.merge(
        primary_session_id,
        secondary_session_ids=[other_session_id],
    )
These operate on the orchestrator that owns the live session, so the management client must be configured to reach it (see the SDK client orchestrator_base_url option). The in-call ctx.session.transfer_to_* helpers remain the simplest way to transfer from within your dialog logic.

Next Steps

Hooks & Events

React to every turn, interruption, silence, and lifecycle event.

SuperDialog Integration

Plug a dialog flow into session.dialog_machine.