strong-air-82340
04/28/2026, 6:13 AMtall-airline-16756
04/28/2026, 6:13 AMtall-airline-16756
04/28/2026, 6:13 AMstrong-air-82340
04/28/2026, 6:18 AMtall-airline-16756
04/28/2026, 6:18 AMwait_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 elementsstrong-air-82340
04/28/2026, 6:21 AMasync 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
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
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.")tall-airline-16756
04/28/2026, 6:22 AMwait_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:
# 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 elementsstrong-air-82340
04/28/2026, 6:24 AMtall-airline-16756
04/28/2026, 6:24 AMawait 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()):
# 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 elementsstrong-air-82340
04/28/2026, 6:32 AMtall-airline-16756
04/28/2026, 6:33 AMctx.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 elementsstrong-air-82340
04/28/2026, 7:36 AMtall-airline-16756
04/28/2026, 7:36 AMwait_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):
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 elementsstrong-air-82340
04/28/2026, 8:43 AMtall-airline-16756
04/28/2026, 8:43 AMwait_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 elementsstrong-air-82340
04/28/2026, 8:49 AMtall-airline-16756
04/28/2026, 8:49 AMwait_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 elementsstrong-air-82340
04/28/2026, 8:55 AMtall-airline-16756
04/28/2026, 8:55 AM_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 elementsstrong-air-82340
04/28/2026, 8:57 AMtall-airline-16756
04/28/2026, 8:57 AMctx.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:
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 elementsstrong-air-82340
04/28/2026, 9:16 AMtall-airline-16756
04/28/2026, 9:16 AMctx.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):
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 elementsstrong-air-82340
04/28/2026, 9:21 AMtall-airline-16756
04/28/2026, 9:21 AMawait 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:
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 elementsstrong-air-82340
04/28/2026, 9:31 AMtall-airline-16756
04/28/2026, 9:31 AMSIPDispatchRule 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)
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