s
hey
✅ 1
t
Thanks for reaching out, Gurkirat. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Hi — thanks for reaching out! How can I help you today? • Are you having an issue with a room, a specific SDK (Web, iOS, Android), authentication, or something else? • If it’s technical, can you share the project ID, room ID, SDK/version, and any error messages or screenshots?, with interactive elements
s
so this is my current logic for a outbound sip call. I first create a room. then create sip participant with wait till answered and then in entrypoint i listen for participant connected event. but i never get this event, but both entrypoint and sip participant creation are not synced so rn I am not really able to figure out if user joined the call. the issue if if user dont even picks up the call agent stil running and transcript is coming and we are spending on not pickup calls also
t
Thanks — I’m addressing your outbound SIP call flow and why you’re not seeing the participant_connected event (and how to avoid running the agent when calls aren’t answered). What I’m responding to: your flow creates a room, then creates a SIP participant with wait_until_answered, and your agent listens for participant_connected but never gets it — causing transcripts and costs when the callee never picks up. What to try (short, actionable): 1. Confirm
wait_until_answered = true
• This prevents the SIP participant from being added to the room until the remote party answers. If you set this, participant_connected will only fire after answer. 1. Register event listener before starting the agent • Ensure your agent’s listener for participant_connected is registered before you call
ctx.connect()
or start the session so you don’t miss the event. 1. Use a timeout for unanswered calls • Have the agent wait for a participant with a bounded timeout (e.g., 20–30 seconds). If no participant_connected arrives, treat the call as unanswered and shut down the agent to stop transcription and costs. 1. Optionally monitor call status externally • After CreateSIPParticipant, poll or use a webhook / job status to detect ringing/answered states. If the SIP call never reaches answered within your timeout, cancel the agent/job and tear down the room. 1. Graceful shutdown to avoid extra costs • When deciding a call is unanswered, shut down the agent with
session.shutdown()
/
ctx.shutdown()
(drain=False) so processing and transcription stop immediately. If you want, share: • the exact SDK/language and code snippets for (a) SIP participant creation and (b) agent startup/ listener registration, and your desired timeout—I’ll point out where to add the timeout and where to register the event listener in your code. Sources: Making calls overview | LiveKit Documentation | Preserving Caller ID During SIP Transfers in LiveKit Agents | Job lifecycle | LiveKit Documentation, with interactive elements
s
this is my code
Copy code
async def _create_telephony_call(
    sip_trunk_id: str,
    phone_number: str,
    tenant_id: str,
    room_name: str,
    participant_name: str,
    participant_identity: str,
    voice_agent: Optional[str] = None,
    metadata: Optional[dict] = None,
) -> tuple[str, str, str]:
    call_id = room_name

    call_ctx = CallSessionCtx(
        room_name=room_name,
        call_id=call_id,
        participant_name=participant_name,
        account_id=tenant_id,
        user_phone=phone_number,
        agent_name=voice_agent,
        metadata=metadata or {},
        call_type="outbound",
        call_channel="telephony",
        inbox_id=metadata.get("inbox_id") if metadata else None,
    )

    # Store context
    await store_call_context(call_ctx.room_name, call_ctx, call_id)

    # Create LiveKit room
    room = await livekit_api_svc.create_room(
        room_name, participant_name=call_ctx.participant_name, call_ctx=call_ctx
    )
    <http://logger.info|logger.info>(f"Created telephony call room: {room_name}, phone: {phone_number}")

    # Create SIP participant
    sip_request = CreateSIPParticipantRequest(
        sip_trunk_id=sip_trunk_id,
        sip_call_to=phone_number,
        room_name=room_name,
        participant_identity=participant_identity,
        participant_name=participant_name,
        krisp_enabled=True,
        wait_until_answered=True,
    )

    participant = await get_livekit_api().sip.create_sip_participant(sip_request)

    await create_call_metrics(call_ctx, "telephony", call_id=call_id, agent_name=voice_agent)

    return room_name, call_id, participant.sip_call_id
and
Copy code
def _register_participant_handlers(ctx: JobContext, call_ctx: CallSessionCtx) -> None:
    """Register room participant_connected / participant_disconnected handlers."""
    from livekit import rtc
    from services.agents.voice._constants import CallEndedBy
    from services.livekt_api import finish_handoff_to_human

    @ctx.room.on('participant_connected')
    def on_participant_connected(participant: rtc.RemoteParticipant):
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] participant connected: %s, metadata: %s", participant, participant.metadata)
        if participant.metadata in ("__crm_human_agent", "__crm_human_agent_telephony"):
            <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] human agent connected (metadata={participant.metadata!r}): %s", participant)
            asyncio.create_task(finish_handoff_to_human(ctx.room.name))
        elif call_ctx.call_type == "outbound" and call_ctx.get_call_channel() == "telephony":
            # SIP participant answered the outbound call
            <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] SIP participant answered outbound call: %s", participant)
            call_ctx.sip_call_answered = True

    @ctx.room.on('participant_disconnected')
    def on_participant_disconnected(participant: rtc.RemoteParticipant):
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] participant disconnected: %s, metadata: %s", participant, participant.metadata)
        if participant.metadata in ("__crm_human_agent", "__crm_human_agent_telephony"):
            <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] human agent disconnected (metadata={participant.metadata!r}): %s", participant)
            call_ctx.call_ended_by = CallEndedBy.HUMAN_AGENT
        else:
            if call_ctx.call_ended_by == CallEndedBy.BOT:
                return
            <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] customer disconnected: %s", participant)
            call_ctx.call_ended_by = CallEndedBy.CUSTOMER
Copy code
async def entrypoint(ctx: JobContext):
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Entrypoint started, job_id={ctx.job.id if hasattr(ctx.job, 'id') else 'N/A'}")

    from services.agents.voice._metrics import create_metrics_tracker
    from services.agents.voice._store import get_config_store
    from services.agents.voice._workflow import WorkflowEngine
    from services.prompt_configs import get_voice_prompt_config
    from livekit import api, rtc  # noqa: F401 — prewarm heavy rtc module

    await _ensure_db_and_tools(ctx)

    # ---- fetch call context ------------------------------------------------
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Fetching call context...")
    call_ctx = await get_call_ctx(ctx)
    <http://logger.info|logger.info>(
        f"[room_id={ctx.room.name}] Call context fetched: account_id={call_ctx.account_id}, "
        f"user_phone={call_ctx.user_phone}, call_id={call_ctx.call_id}, "
        f"agent_name={call_ctx.agent_name}, call_type={call_ctx.call_type}, inbox_id={call_ctx.inbox_id}"
    )

    # For outbound call without ai agent, we simply connect
    if call_ctx.call_type == "outbound" and not call_ctx.agent_name:
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Outbound call without AI agent, shutting down job")
        ctx.shutdown("outbound call without AI agent")
        return

    await ctx.connect()

    # ---- check if AI agent is disabled -------------------------------------
    if _parse_ai_agent_enabled(ctx.room.metadata) is False:
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] AI agent explicitly disabled in room metadata, handling as human agent handoff")
        await handle_no_agent_scenario(ctx, call_ctx)
        return

    # ---- parallel config fetch ---------------------------------------------
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Fetching voice config, agent configs, and voice_enabled check in parallel...")
    config_store = get_config_store()
    from services.agents.voice._utils import is_voice_enabled
    voice_enabled, config, preloaded_configs = await asyncio.gather(
        is_voice_enabled(str(call_ctx.account_id), call_ctx.call_type or "inbound", call_ctx.inbox_id, call_ctx.agent_name),
        get_voice_prompt_config(str(call_ctx.account_id)),
        config_store.load_all_agents(str(call_ctx.account_id)),
    )
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Parallel fetch done: voice_enabled={voice_enabled}, agents={len(preloaded_configs)}")

    if not voice_enabled:
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] No enabled voice agents found for account {call_ctx.account_id}, handling as human agent handoff")
        await handle_no_agent_scenario(ctx, call_ctx)
        return

    # ---- register participant handlers (needs voice_enabled branch passed) -
    _register_participant_handlers(ctx, call_ctx)

    if call_ctx.call_type == "outbound" and call_ctx.get_call_channel() == "telephony":
        from livekit import rtc
        already_joined = next(
            (p for p in ctx.room.remote_participants.values()
             if p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP),
            None,
        )
        if already_joined:
            <http://logger.info|logger.info>(
                f"[room_id={ctx.room.name}] SIP participant already in room (joined before handler registered): %s",
                already_joined,
            )
            call_ctx.sip_call_answered = True

    # ---- build workflow and resolve entry agent -----------------------------
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Creating workflow engine...")
    workflow = WorkflowEngine(
        call_ctx.account_id,
        agent_name=call_ctx.agent_name,
        call_type=call_ctx.call_type or 'inbound',
        inbox_id=str(call_ctx.inbox_id) if call_ctx.inbox_id else None,
        preloaded_configs=preloaded_configs,
    )
    entry_agent = workflow.entry_agent
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Entry agent selected: {entry_agent}")

    if entry_agent is None:
        logger.warning(
            f"[room_id={ctx.room.name}] No enabled entry agent found for account {call_ctx.account_id}, "
            f"call_type={call_ctx.call_type or 'inbound'}, handling as human agent handoff"
        )
        await handle_no_agent_scenario(ctx, call_ctx)
        return

    # ---- initialise entry agent plugins ------------------------------------
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Initializing entry agent plugins: {entry_agent.config.name}")
    entry_cfg = preloaded_configs[entry_agent.config.name]
    entry_view = entry_cfg.as_voice_agent_doc_view()
    _init_agent_plugins(entry_cfg, entry_view, config, call_ctx, ctx.room.name)
    entry_agent.agent._stt = entry_cfg.stt
    entry_agent.agent._tts = entry_cfg.tts
    entry_agent.agent.stt_name = entry_cfg.stt_name
    entry_agent.agent.tts_name = entry_cfg.tts_name
    entry_agent.agent.llm_name = entry_cfg.llm_name
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Entry agent plugins ready")

    # Background: init remaining agents for potential handoffs
    if len(preloaded_configs) > 1:
        asyncio.create_task(
            _init_remaining_agent_plugins(preloaded_configs, entry_agent.config.name, config, call_ctx, workflow, ctx.room.name)
        )

    # ---- wire call context -------------------------------------------------
    call_ctx.personas = workflow.agents
    call_ctx.prev_agent = None
    call_ctx.room = ctx.room
    call_ctx.background_audio = None

    # ---- build session -----------------------------------------------------
    session = _build_agent_session(ctx, config, entry_agent.agent, call_ctx)
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] AgentSession created successfully")

    # ---- finalise call context (metadata, agent_id, Redis) -----------------
    await _finalize_call_ctx(call_ctx, entry_agent, ctx)

    # ---- initialise metrics tracker ----------------------------------------
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Initializing metrics tracker...")
    call_ctx.metrics_tracker = await create_metrics_tracker(call_ctx, entry_agent.config)
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Metrics tracker initialized successfully")

    # ---- inject system instructions ----------------------------------------
    voice_agent = entry_agent.agent
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Injecting system instructions...")
    try:
        voice_agent._instructions = await system_instructions_injection(voice_agent.instructions, call_ctx)
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] System instructions injected successfully")
    except Exception as e:
        logger.error(f"[room_id={ctx.room.name}] Failed to inject system instructions: {e}", exc_info=True)
        raise

    asyncio.create_task(update_call_connected({"call_id": call_ctx.call_id}))

    # ---- start session -----------------------------------------------------
    _nc = ctx.proc.userdata.get("noise_cancellation") if isinstance(ctx.proc.userdata, dict) else None
    <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Starting agent session...")
    try:
        from livekit.agents import room_io
        from livekit.plugins import noise_cancellation
        await session.start(
            room=ctx.room,
            agent=voice_agent,
            room_options=room_io.RoomOptions(
                audio_input=room_io.AudioInputOptions(
                    noise_cancellation=_nc if _nc is not None else noise_cancellation.BVC(),
                )
            ),
        )
        <http://logger.info|logger.info>(f"[room_id={ctx.room.name}] Agent session started successfully")
    except Exception as e:
        logger.error(f"[room_id={ctx.room.name}] Failed to start agent session: {e}", exc_info=True)
        raise

    # ---- post-start: background audio + speak-first ------------------------
    if getattr(voice_agent, 'background_audio_enabled', False):
        <http://logger.info|logger.info>(f"Background audio enabled for room {ctx.room.name}, starting playback loop")
        await background_audio_playback(ctx, session, call_ctx)

    if getattr(voice_agent, 'speak_first', 'user') == 'agent':
        <http://logger.info|logger.info>(f"Speaking first as agent for room {ctx.room.name}")
        await session.generate_reply(instructions="Greet the user and offer your assistance.")
t
Thanks — this is helpful. I looked at your code and the problem and here's a concise plan to fix it and avoid running the agent for unanswered calls. What I’m responding to: your outbound flow creates the room, creates the SIP participant with
wait_until_answered=True
, then starts the agent and registers handlers — but you never reliably see
participant_connected
, and transcripts run when the callee never picks up. Immediate fixes (apply to your code): 1. Register handlers before connecting the agent • Move
_register_participant_handlers(ctx, call_ctx)
to before
await ctx.connect()
so the room event listeners exist before any join/connect events can occur. 1. Use
wait_until_answered=True
(you already do) • With this set, the SIP participant will only appear in the room after the call is answered. That means if no
participant_connected
arrives, the call wasn’t answered. 1. Add a bounded wait / timeout for the SIP participant • After
await ctx.connect()
(or immediately after registering handlers if you move connect later), wait for the SIP participant with a timeout. Example pattern: a. Check existing participants for a SIP participant (you already do this with
already_joined
). b. If not found, wait up to N seconds (e.g., 20–30s) polling
ctx.room.remote_participants
or use
ctx.wait_for_participant()
if available, then mark unanswered when timeout fires. • If timeout expires, set
call_ctx.sip_call_answered = False
and shut down. 1. Shutdown quickly on unanswered calls to avoid transcription costs • If the call is unanswered after timeout, call
ctx.shutdown("no answer")
or `session.shutdown()`/`ctx.shutdown()` with drain disabled so the agent stops and transcription stops immediately. 1. Ensure you don’t miss early joins • You already guard with the
already_joined
check; keep that but run it immediately after handler registration so joins that happened just before are caught. Suggested minimal code edits: • Move
_register_participant_handlers(ctx, call_ctx)
above
await ctx.connect()
. • After connect and the
already_joined
check, insert a wait-with-timeout:
Copy code
# after registering handlers and checking already_joined
if not call_ctx.sip_call_answered:
    try:
        await asyncio.wait_for(_wait_for_sip_participant(ctx), timeout=30.0)
    except asyncio.TimeoutError:
        logger.info(f"[room_id={ctx.room.name}] SIP participant did not answer within timeout, shutting down")
        await update_call_connected({"call_id": call_ctx.call_id, "answered": False})
        ctx.shutdown("no answer")
        return
and implement
_wait_for_sip_participant(ctx)
to poll
ctx.room.remote_participants
for a participant with
kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP
or await a helper that resolves when
participant_connected
fires. If you want, I can: • Propose the exact implementation of
_wait_for_sip_participant
in your code style, or • Show a drop-in patch moving the handler registration and adding the timeout where it fits in your entrypoint. Which would you prefer?, with interactive elements
s
what the correct approach for this this waiting for 30 sec is hacky
t
You're right — a hard 30s sleep feels hacky. Use an event-driven wait instead. What to do (concise): • Register your participant handlers before connecting. • Use the job context helper
await ctx.wait_for_participant()
to block until a participant joins (this returns the RemoteParticipant when the SIP leg is answered). This is the intended, non‑hacky approach. • If you need a safety cap, wrap it in
asyncio.wait_for(...)
to fail fast, but prefer relying on
wait_for_participant()
alone. Minimal example integration (fits into your entrypoint after registering handlers and after
await ctx.connect()
):
Copy code
# after registering handlers and connecting
try:
    participant = await ctx.wait_for_participant()        # event-driven wait
    logger.info("SIP participant joined: %s", participant)
    call_ctx.sip_call_answered = True
except asyncio.TimeoutError:
    logger.info("SIP participant did not join within timeout, shutting down")
    ctx.shutdown("no answer")
    return
Notes: • You already set
wait_until_answered=True
on SIP creation, so the SIP participant will only appear after the remote party answers. • Keep your existing
already_joined
check immediately after handler registration to catch any early joins. • Use a small optional timeout only as a guardrail; the primary logic should be event-driven via
ctx.wait_for_participant()
. If you want, I can draft the exact patch moving the handler registration, placing
ctx.connect()
, and inserting
ctx.wait_for_participant()
where it best fits in your entrypoint. Which would you prefer? , with interactive elements
s
is there any doc i can refer where its mentioned that i must register events before ctx.connect()
t
You're right to ask — register event handlers before calling
ctx.connect()
. Why: registering listeners first ensures you don't miss events that can fire immediately when the session connects (including participant joins). The non‑hacky, recommended pattern is to register handlers, call
await ctx.connect()
, then use
await ctx.wait_for_participant()
to wait for the SIP leg to be answered. You can optionally wrap
wait_for_participant()
in
asyncio.wait_for()
as a short guardrail. Documentation: • Job lifecycle / agent guidance: https://docs.livekit.io/agents/server/job/ Sources: Job lifecycle | LiveKit Documentation, with interactive elements
s
but i get this event before picking up the call it happend if only ringing start
t
Thanks — good detail. You're seeing participant_connected during ringing because that event means a SIP leg is present in the room (which can happen on early media). Here’s how to reliably treat only a true ANSWER as “answered.” What I’m responding to: participant_connected fires on ringing, not only after answer. Checks to run now • Confirm you set
wait_until_answered=True
(you do). If LiveKit + your trunk honor it, the SIP participant should not appear until ANSWERED. If you still see joins on ringing, the trunk is likely sending early media or the flag isn’t being enforced. • Log the CreateSIPParticipant response (including
sip_call_id
) so you can correlate with provider state and webhooks. Reliable approaches (pick one): • Best (event-driven + authoritative): a. Keep
await ctx.wait_for_participant()
or participant_connected as your event trigger. b. Immediately after the join, query your SIP provider (or LiveKit SIP API if it reports state) for the call status using
sip_call_id
. Proceed only if status == ANSWERED. • If provider doesn’t expose an ANSWER event: ◦ After participant join, verify media flow (short guard window 5–10s) or look for audio packets before starting transcription. If no media/ANSWER appears, shutdown. Small code sketch to gate start (fits your entrypoint):
Copy code
participant = await ctx.wait_for_participant()
# confirm via provider
status = await sip_provider.get_call_status(call_id=sip_call_id)
if status != "ANSWERED":
    ctx.shutdown("no answer")
    return
# safe to start agent/session
Notes • Use the provider webhook or call-status API as the primary source of truth. Relying on participant_connected alone can be fooled by early media. • Use a short guard window only when the provider cannot reliably report ANSWERED. If you share your SIP trunk/provider or the CreateSIPParticipant response/webhook payloads you receive, I’ll point to the exact fields/events to check and give a copy-paste snippet to plug into your code. , with interactive elements
s
wait_until_answered=True is not working is this could be some config issue prom plivo or I am doint somehting wrong wait_until_answered dont wait at all
t
Thanks — I hear your frustration. You set
wait_until_answered=True
but the SIP participant still appears during ringing, so the flag is effectively not delaying the join. That can be caused by either provider behavior (early media / provisional answers) or a config/mismatch. Here’s how to diagnose and fix it. Quick checks (do these first) • Confirm the CreateSIPParticipantRequest you send actually contains
wait_until_answered=True
and that the API call succeeds (log the full response, especially
sip_call_id
). • Check LiveKit server logs around SIP creation and incoming SIP events to see whether LiveKit received an early/ provisional response (like 183 Session Progress) from the provider. • Inspect Plivo’s call logs/webhooks for the same call (
sip_call_id
) and note the SIP responses you see: RINGING (180), PROGRESS (183), ANSWER (200). If you see 183/180 before 200, that explains early media. Likely causes • Early media / provisional responses: Some trunks send 183/180 (early media) which can cause a SIP leg/socket to be considered “present” even before 200 OK. If LiveKit or the trunk treats early media as a join, participant_connected will fire. • Provider not honoring wait_until_answered: If Plivo immediately negotiates media or forwards early media, LiveKit’s wait flag can be bypassed. • Misconfiguration in trunk or SIP headers: Your trunk or Plivo application may be configured to send early media or to proxy provisional responses in a way LiveKit interprets as connected. Actionable remediation steps 1. Correlate events with provider webhooks ◦ Use the
sip_call_id
from LiveKit and compare LiveKit logs to Plivo call/webhook events. Confirm which SIP response corresponds to the participant_connected timestamp. 1. Ask Plivo to disable early media / provisional bridging (if possible) ◦ Look for settings around “early media,” “ringback behavior,” or “send 183” and disable them so the provider only establishes media on 200 OK (ANSWER). 1. Use provider call status as source of truth ◦ After participant_connected, call Plivo’s call status API or rely on Plivo webhooks and only treat the call as answered when you see 200 OK / call status = ANSWERED. If not ANSWERED, shutdown the agent. 1. If provider cannot change behavior: gate by media ◦ Check for actual RTP audio flow from the SIP leg (e.g., detect audio packets or non-silence for a short verification window) before starting transcription/agent work. 1. Share logs if you want help ◦ If you share the CreateSIPParticipant response, LiveKit logs around the SIP call, and the Plivo webhook payloads for that call (timestamps + SIP codes), I can point to the exact field and recommend whether to change provider settings or change handling code. Short recommended code pattern (combine event + provider check) • Continue to use event-driven
ctx.wait_for_participant()
but after the participant appears, call your provider API / check webhook for ANSWERED. Only start the session when provider state == ANSWERED. If you’d like, paste a sample Plivo webhook payload or the LiveKit SIP logs for one failing call and I’ll analyze them and tell you whether this is Plivo behavior or something you can fix in your code. , with interactive elements
s
checked the call this wait till answered not wait for pickup this wait for call initiation
t
Thanks — this is clear and helpful. You’re saying
wait_until_answered=True
is behaving like “wait for call initiation” (ringing/early media) rather than waiting for pickup (200 OK). That can happen when the SIP trunk or provider sends provisional responses/early media which LiveKit (or the trunk) treats as a connected leg. What to do next (concise steps): 1. Correlate LiveKit and Plivo events • Grab the LiveKit CreateSIPParticipant response (contains
sip_call_id
) and LiveKit SIP logs around that call. • Pull the Plivo webhooks / call logs for the same call. Note SIP responses: 180, 183, 200 and timestamps. 1. Confirm whether early media/provisional responses exist • If you see 180/183 before 200, that explains why the participant appears before pickup. 1. Try changing Plivo/trunk settings • Ask Plivo to disable early media / provisional bridging or ringback proxying so media and join occur only after 200 OK. 1. Use provider state as authoritative • Keep event-driven handling (register handlers before connect and use
await ctx.wait_for_participant()
), but after participant join verify Plivo call status is ANSWERED (200 OK) before starting the agent/transcription. If provider reports not-answered, shut down. 1. Fallback if provider can’t change behavior • Gate start on actual RTP/audio detection (short verification window) or require explicit 200 OK from provider webhooks. If you want, paste: • a CreateSIPParticipant response (with sip_call_id), • corresponding LiveKit SIP log entries, and • the Plivo webhook payloads for that call —I’ll analyze the timestamps/SIP codes and tell you whether this is a Plivo behavior or a config you can change, and give the exact code snippet to gate by provider status. , with interactive elements
s
[no preview available]
t
Thanks — I can help dig into this. To pinpoint whether this is a Plivo/config issue or something in your flow, please do two quick things and paste the results here: 1) Confirm ordering • Did you register
_register_participant_handlers(ctx, call_ctx)
before
await ctx.connect()
in the failing run? If not, please move it and retest. 2) Share one failing call’s logs/payloads (timestamps help) • The CreateSIPParticipant API response (include
sip_call_id
). • LiveKit SIP/server logs around that
sip_call_id
(showing SIP codes/events). • Plivo webhook payloads or call logs for the same call (showing SIP responses like 180, 183, 200 and timestamps). What I’ll look for: • Whether LiveKit sees provisional responses (180/183) before 200 OK (explains join on ringing). • If wait_until_answered was present in the request and how LiveKit treated it. • Which side (Plivo or LiveKit) is interpreting early media as “answered.” If you prefer, paste anonymized payloads/snippets and I’ll analyze and tell you the exact fix (trunk setting to change, or a code gate to require provider 200 OK or RTP audio) and provide the precise code to implement it. , with interactive elements
s
yes i can see this is the order invite 100 183 200 in plivo
t
Thanks — seeing INVITE → 100 → 183 → 200 from Plivo confirms the issue: Plivo sends a provisional 183 (early media), and LiveKit is treating that as a SIP leg being present, so participant_connected fires on ringing rather than on pickup. What to do next (concise): 1. Short-term code guard (recommended) • Keep event-driven flow +
ctx.wait_for_participant()
. • After participant joins, verify Plivo’s authoritative state (use Plivo call status API or webhook). Only start the agent when you see 200 OK / status == ANSWERED. Example pattern:
Copy code
participant = await ctx.wait_for_participant()
status = await plivo.get_call_status(sip_call_id)   # call Plivo API or check webhook state
if status != "answered":
    ctx.shutdown("no answer")
    return
# proceed to start agent/session
1. Long-term / ideal fix (eliminate false joins) • Ask Plivo to disable early media / provisional bridging (stop sending 183 or stop proxying early media) so media/connect happens only on 200 OK. This makes
wait_until_answered=True
work as you expect. 1. Alternative if Plivo can’t change behavior • Gate on actual RTP audio (detect media packets or short non-silence window) before enabling transcription/agent work. 1. Diagnostics to confirm • Correlate LiveKit logs (sip_call_id) with Plivo webhooks. If you paste one CreateSIPParticipant response + the Plivo webhook payloads for that call, I’ll point exactly which field/time to check and provide a drop‑in snippet to gate on Plivo’s ANSWER event. If you want, I can draft the exact Plivo API/webhook check code for your stack. Which would help more: a webhook-based solution or polling the Plivo call status API? , with interactive elements
s
no call is also getting blocked but I am getting event from livekti for participant joined before the that block
t
Thanks — that makes sense and I understand the frustration. What I’m responding to: you’re still seeing LiveKit fire participant_joined before the call is actually answered/connected (Plivo sends 183 early media), and sometimes the call is blocked later. Why that happens (short): Plivo’s 183 (early media) causes a SIP leg/media to be established before the 200 OK. LiveKit sees the early media as a present participant, so participant_joined can fire before the real ANSWER. What to do next (concrete options): • Immediate, reliable fix (recommended) a. Keep the event-driven flow and register handlers before
ctx.connect()
. b. After you get the participant (via
await ctx.wait_for_participant()
or participant_joined), verify Plivo’s authoritative state (HTTP API or webhook) for that
sip_call_id
. Only start the agent when Plivo reports ANSWERED (200 OK). If not ANSWERED, shutdown the job. • Long term (ideal) ◦ Ask Plivo to disable early media/provisional bridging for your trunk so 183/early media is not treated as an established leg. Then
wait_until_answered=True
will behave as expected. • If Plivo can’t change behavior ◦ Gate by observing RTP/media (detect audio packets or non-silence for a short window) before enabling transcription/agent processing. Quick example (use after registering handlers and connecting):
Copy code
participant = await ctx.wait_for_participant()
status = await plivo.get_call_status(sip_call_id)
if status != "answered":
    ctx.shutdown("no answer")
    return
# proceed to start session
If you want I can: • Draft the exact webhook-or-polling check for Plivo in your codebase, or • Analyze one failing call if you paste the CreateSIPParticipant response (sip_call_id), the LiveKit SIP logs for that sip_call_id, and the Plivo webhook payloads — I’ll point to the exact field/timestamp and the minimal code change to gate on 200 OK. Which would you prefer?, with interactive elements
s
ok so wait_tiilll_asnwered works, but event is comping before wait till answered wala response
t
Thanks — this is clear. You’re seeing participant_connected before pickup because Plivo sends 183 (early media); LiveKit treats that as a SIP leg being present, so the participant appears before the 200 OK. What to do next (concise): • Keep registering handlers before
await ctx.connect()
and use
await ctx.wait_for_participant()
(event-driven). • After the participant appears, verify the provider’s authoritative state (Plivo) for that
sip_call_id
and only start the agent when you see ANSWERED / 200 OK. Example:
Copy code
participant = await ctx.wait_for_participant()
status = await plivo.get_call_status(sip_call_id)
if status != "answered":
    ctx.shutdown("no answer")
    return
# now start agent/session
• Long‑term: ask Plivo to disable early media / provisional bridging so 183 isn’t treated as a connected leg; then
wait_until_answered=True
will behave as you expect. • If Plivo can’t change behavior: gate on RTP/audio (detect media packets or non‑silence for a short verification window) before enabling transcription. If you want, paste one CreateSIPParticipant response (sip_call_id) + Plivo webhook payloads for a failing call and I’ll point to the exact field/timestamp and give a ready‑to‑drop snippet for polling/webhook handling. , with interactive elements
s
what is SIPDispatchRule
t
Good question — here’s a concise explanation. What a SIPDispatchRule is • A
SIPDispatchRule
is a routing configuration that tells LiveKit how to map incoming SIP calls (from one or more SIP trunks) into LiveKit rooms. • It controls routing behavior such as: which trunk(s) it applies to (
trunk_ids
), how room names are created (room prefix or direct room name), whether a PIN is required, and optional room-level settings (e.g., auto‑egress/recording, hide phone numbers). Common types • Individual rule: create a new room per call (uses
room_prefix
). • Direct rule: route to a specific pre-defined room. • Callee-based rule: route by called number (DNIS), optionally with PIN logic. Why it matters • Use dispatch rules to automatically create/join the correct room when an inbound SIP call arrives and to control how calls are handled (e.g., which room an AI agent or human should be dispatched to). Example (Python-style)
Copy code
dispatch_rule = await lk_api.sip.create_sip_dispatch_rule(
    CreateSIPDispatchRuleRequest(
        trunk_ids=[inbound_trunk.sip_trunk_id],
        rule=SIPDispatchRule(
            dispatch_rule_individual=SIPDispatchRuleIndividual(room_prefix="call")
        )
    )
)
This produces rooms named like
call-{uuid}
for inbound calls on that trunk. Where to read more • LiveKit docs: https://docs.livekit.io/agents/start/telephony/ • KB article with Python examples: https://kb.livekit.io/articles/5792506227-creating-sip-inbound-trunks-and-dispatch-rules-with-python-api If you want, tell me how you plan to route calls (create-per-call, direct room, or DNIS-based) and I’ll give a tailored example and the exact request fields you should set. Sources: Agents telephony integration | LiveKit Documentation | Creating SIP Inbound Trunks and Dispatch Rules with Python SDK, with interactive elements