boundless-author-7811
12/16/2025, 4:53 PM# Create SIP participant via LiveKit API
await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer",
max_call_duration=timedelta(seconds=180), # 3 minutes max
wait_until_answered=True
)
)
how can i know if the user picks the call, is busy or rejected?tall-airline-16756
12/16/2025, 4:53 PMtall-airline-16756
12/16/2025, 4:53 PMcreate_sip_participant call returns successfully.
• At that point the participant’s sip.callStatus will be active (or automation briefly for some automated flows).
2) Handle errors for busy / rejected / failed
• If the call fails (rejected, busy, invalid number, etc.), create_sip_participant raises an error you can catch.
• Inspect the error metadata for SIP status information: sip_status_code and sip_status.
• Common SIP codes to check:
◦ 486 — Busy (Destination busy)
◦ 487 — Request terminated (canceled/rejected)
◦ 404 — Not found (invalid number)
◦ Other 4xx/5xx codes indicate other failures
Example pattern (Python-like):
try:
await ctx.api.sip.create_sip_participant( ... wait_until_answered=True ...)
# call answered -> participant connected, sip.callStatus == "active"
except api.TwirpError as e:
sip_status_code = e.metadata.get("sip_status_code")
sip_status = e.metadata.get("sip_status")
# inspect sip_status_code to distinguish busy/rejected/not found/etc.
3) Real-time monitoring (alternative to blocking)
• Set wait_until_answered=False and watch participant events/attributes.
• Monitor the participant’s sip.callStatus values:
◦ dialing / ringing — call in progress
◦ active — answered/connected
◦ hangup — call ended
• Use these events if you need non-blocking behavior or finer-grained state transitions.
If you’d like, tell me which approach you prefer (blocking with wait_until_answered or non-blocking event monitoring) and I’ll show a precise code example for that path.
Sources: SIP participant | LiveKit docs | Make outbound calls | LiveKit docsboundless-author-7811
12/16/2025, 4:56 PMtall-airline-16756
12/16/2025, 4:57 PMwait_until_answered=True (simple, returns or errors) and (B) non‑blocking event/attribute monitoring (more control, real‑time states). Each example is Python-style matching your snippet.
A — Blocking (wait until answered)
1. Call create_sip_participant with wait_until_answered=True.
2. If it returns, the call was answered (participant sip.callStatus becomes active).
3. If it raises an error, inspect e.metadata for sip_status_code / sip_status to distinguish busy/rejected/etc.
Example:
from datetime import timedelta
try:
participant = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name="Customer",
max_call_duration=timedelta(seconds=180),
wait_until_answered=True
)
)
# Success -> call answered
print("Call answered")
# participant object available; you can check participant.metadata or attributes
# e.g., participant.metadata.get("sip.callStatus") or query the participant later
except api.TwirpError as e:
sip_status_code = e.metadata.get("sip_status_code")
sip_status = e.metadata.get("sip_status")
print(f"Call failed: {sip_status_code} {sip_status}")
if sip_status_code == "486":
print("Busy")
elif sip_status_code == "487":
print("Rejected / canceled")
elif sip_status_code == "404":
print("Number not found")
else:
print("Other failure")
When to use: quick implementation when you want the request to complete only once the call is answered (or definitively fails). Simpler server flow but ties up the caller until answer/failure.
B — Non‑blocking (monitor events / participant attributes)
1. Create the SIP participant without blocking: wait_until_answered=False (default).
2. Subscribe to room/participant events or poll participant attributes to observe sip.callStatus transitions: dialing, ringing, active, hangup, etc.
3. Use those transitions to detect answered, busy, rejected, or ended.
Example pattern:
# 1) Create without waiting
await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name="Customer",
max_call_duration=timedelta(seconds=180),
wait_until_answered=False
)
)
# 2) In your room/participant event handler (pseudo-code):
def on_participant_updated(participant):
call_status = participant.metadata.get("sip.callStatus") # or attribute on participant
if call_status == "dialing":
print("Dialing...")
elif call_status == "ringing":
print("Ringing at destination")
elif call_status == "active":
print("Call answered / connected")
elif call_status == "hangup":
# look for hangup reason or final sip status if provided
print("Call ended")
Or subscribe to the room’s real-time participant events (your client/agent library event callbacks). Use those events to update UI, trigger timers, or log final SIP codes if your integration surfaces them in participant metadata/events.
When to use: you need non-blocking behavior, want to update UI in real time, or need intermediate states like ringing/dialing. Slightly more complex but gives full lifecycle visibility.
Notes and tips
• Error metadata contains SIP response codes. Check sip_status_code to detect busy (486), canceled/rejected (487), not found (404), etc.
• sip.callStatus == "active" indicates the call is connected.
• Use blocking for simple flows; use event monitoring for real-time UI or complex logic.
Which example would you like next: a full runnable server example showing event subscription, or a more detailed client-side participant event handler?boundless-author-7811
12/17/2025, 6:16 AM# Create SIP participant via LiveKit API
try:
participant = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{call_context.customer_data.customer_name}",
max_call_duration=timedelta(seconds=180), # 3 minutes max
wait_until_answered=True
)
)
<http://self.logger.info|self.logger.info>(
"[%s] ✅ SIP participant created: %s",
room_name,
participant_identity
)
<http://self.logger.info|self.logger.info>("**************Logging value of participant: %s", participant)
return participant_identity
except api.TwirpError as e:
self.logger.error(
"[%s] ❌ Failed to create SIP participant: %s",
room_name,
e,
exc_info=True
)
raise RuntimeError(f"Failed to initiate outbound call: {e}") from e
{"level": "INFO", "name": "orchestrator.call_orch_service", "message": "**************Logging value of participant: participant_id: \"PA_kjjkvQ3Y6aAT\"\nparticipant_identity: \"sip_target_1234567890\"\nroom_name: \"collection-call-3148067097691554840-1765948700\"\nsip_call_id: \"SCL_nDQABwRQk457\"\n", "pid": 258, "job_id": "AJ_EG29f8wxUdD4", "room_id": "RM_CD8iPTnSg4ck", "timestamp": "2025-12-17T05:18:28.725359+00:00"} but i do not see any metadata fieldtall-airline-16756
12/17/2025, 6:16 AMcreate_sip_participant (id, identity, sip_call_id) because that API returns only the initial participant info synchronously. The SIP call state is not included there as metadata.
What to do next (two practical options):
1) Blocking flow (you already use this)
• wait_until_answered=True will only return successfully once the call is answered (or definitively fails). A successful return means the call is connected; check the participant later and you should see sip.callStatus == "active".
• Immediate failures (invalid number, trunk error, provider rejections) raise api.TwirpError. Inspect e.metadata.get("sip_status_code") and e.metadata.get("sip_status") to detect busy (486), canceled/rejected (487), not found (404), etc.
2) Non‑blocking / real‑time monitoring (recommended for full lifecycle)
• Create the SIP participant with wait_until_answered=False.
• Subscribe to participant attribute change events or poll GetParticipant. Watch the sip.callStatus attribute for these transitions:
◦ dialing — initiating
◦ ringing — remote is ringing
◦ active — answered/connected
◦ automation — automated flow / DTMF
◦ hangup — call ended/rejected
• Example pattern:
# create without waiting
await ctx.api.sip.create_sip_participant(..., wait_until_answered=False)
# later, get participant state
res = await ctx.api.room.get_participant(RoomParticipantIdentity(room=room_name, identity=participant_identity))
attrs = res.participant.attributes
status = attrs.get("sip.callStatus")
Or add an event handler to receive participant attributes changed events and react to sip.callStatus updates in real time.
Debug tips
• Log e.metadata inside your exception handler when create_sip_participant fails to capture SIP error codes.
• Log attribute-change events after creation to confirm updates are arriving asynchronously.
• Don’t expect call status to appear on the initial participant return value — it’s populated later via attributes/events.
If you want, I can:
• Show a concrete async example that subscribes to participant attribute events, or
• Show a fuller runnable snippet that calls get_participant until sip.callStatus transitions. Which would help you most? Sources: Making outbound calls | LiveKit docs | Make outbound calls | LiveKit docs | SIP participant | LiveKit docs | Handling events | LiveKit docs | Participant attributes and metadata | LiveKit docsboundless-author-7811
12/17/2025, 6:18 AMget_participant until sip.callStatus transitions. Which would help you most?tall-airline-16756
12/17/2025, 6:18 AMget_participant until sip.callStatus reaches a terminal/connected state or times out,
• inspects Twirp error metadata for immediate failures.
Adjust imports and API client calls to match your environment.
import asyncio
from datetime import timedelta, datetime, timezone
# replace `api` and ctx.api with your actual LiveKit client objects
# this example assumes `ctx.api.sip.create_sip_participant` and `ctx.api.room.get_participant`
# behave like in your snippets and return the shown objects.
POLL_INTERVAL = 1.0 # seconds between polls
POLL_TIMEOUT = 30.0 # max seconds to wait for a status transition
async def create_and_wait_for_call(ctx, room_name, trunk_id, dial_number, participant_identity):
try:
# Create participant non-blocking (we'll poll for status)
await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{participant_identity}",
max_call_duration=timedelta(seconds=180),
wait_until_answered=False
)
)
except api.TwirpError as e:
# Immediate failure from provider; inspect metadata
sip_code = e.metadata.get("sip_status_code")
sip_msg = e.metadata.get("sip_status")
raise RuntimeError(f"Immediate SIP failure: {sip_code} {sip_msg}") from e
# Poll for participant attributes until callStatus changes to a terminal state
deadline = datetime.now(timezone.utc).timestamp() + POLL_TIMEOUT
last_status = None
while True:
# timeout
if datetime.now(timezone.utc).timestamp() > deadline:
raise TimeoutError(f"Timed out waiting for sip.callStatus (last={last_status})")
# fetch participant state
res = await ctx.api.room.get_participant(
api.RoomParticipantIdentity(room=room_name, identity=participant_identity)
)
participant = res.participant
attrs = participant.attributes or {}
call_status = attrs.get("sip.callStatus")
if call_status != last_status:
# log or act on state changes
<http://ctx.logger.info|ctx.logger.info>("Participant %s sip.callStatus -> %s", participant_identity, call_status)
last_status = call_status
# Interpret statuses
if call_status == "active":
# answered / connected
return {"status": "answered", "participant": participant}
if call_status == "ringing":
# still ringing; continue polling
pass
if call_status == "dialing":
pass
if call_status == "automation":
# DTMF/automation in progress
pass
if call_status == "hangup":
# final hangup - may include final SIP error details in participant attributes or logs
return {"status": "hangup", "participant": participant}
# If provider returns explicit SIP result codes via attributes, inspect them:
sip_code_attr = attrs.get("sip.status_code") or attrs.get("sip_status_code")
if sip_code_attr:
# map common codes
if sip_code_attr == "486":
return {"status": "busy", "code": sip_code_attr, "participant": participant}
if sip_code_attr == "487":
return {"status": "rejected", "code": sip_code_attr, "participant": participant}
if sip_code_attr == "404":
return {"status": "not_found", "code": sip_code_attr, "participant": participant}
await asyncio.sleep(POLL_INTERVAL)
Notes:
• Use wait_until_answered=False so you receive the initial participant quickly and can poll.
• The authoritative call state is sip.callStatus in participant attributes; initial create response will not contain this.
• Immediate provider failures may raise TwirpError — check e.metadata for sip_status_code / sip_status.
• Tune POLL_INTERVAL and POLL_TIMEOUT to your latency and UX needs.
If you want, I can convert this to a runnable script with mock client stubs or show how to wire this into your existing event loop and logging.boundless-author-7811
12/17/2025, 6:20 AMtall-airline-16756
12/17/2025, 6:20 AMwait_until_answered=True behaves and a concise example showing how to use it and handle outcomes.
Behavior summary
• wait_until_answered=True blocks the API call until the outbound SIP INVITE reaches a terminal connected state (answered/connected) or the provider returns an error immediately.
• If it returns successfully, the call is connected (you can expect sip.callStatus to be active shortly after).
• If the call fails immediately (invalid number, carrier rejection, busy in some cases), the call raises api.TwirpError. Inspect e.metadata for SIP details (sip_status_code, sip_status) to detect busy (486), canceled/rejected (487), not found (404), etc.
• The synchronous return value contains basic participant info; full sip.callStatus may still be populated asynchronously — but successful return implies connection.
Example (blocking)
from datetime import timedelta
try:
participant = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{participant_identity}",
max_call_duration=timedelta(seconds=180),
wait_until_answered=True
)
)
<http://ctx.logger.info|ctx.logger.info>("Call answered — participant created: %s", participant_identity)
# Optionally verify attributes once returned
res = await ctx.api.room.get_participant(
api.RoomParticipantIdentity(room=room_name, identity=participant_identity)
)
call_status = (res.participant.attributes or {}).get("sip.callStatus")
<http://ctx.logger.info|ctx.logger.info>("sip.callStatus after return: %s", call_status) # expect "active"
return {"status": "answered", "participant": participant}
except api.TwirpError as e:
sip_code = e.metadata.get("sip_status_code")
sip_msg = e.metadata.get("sip_status")
ctx.logger.error("Failed to create SIP participant: %s %s", sip_code, sip_msg)
if sip_code == "486":
return {"status": "busy", "code": sip_code}
if sip_code == "487":
return {"status": "rejected", "code": sip_code}
if sip_code == "404":
return {"status": "not_found", "code": sip_code}
# other failures
raise RuntimeError(f"Failed to initiate outbound call: {sip_code} {sip_msg}") from e
Notes and tips
• Use the blocking option when you want a simple flow that only continues after a successful answer. It keeps your server flow straightforward but ties up the awaiting task until answer/failure.
• Even with wait_until_answered=True, log and inspect e.metadata in the exception handler to classify busy/rejected/etc.
• If you need intermediate states (ringing/dialing) or faster UI updates, use the non‑blocking approach with attribute events instead.
Want a version of this example integrated into your existing function (with your logger and error handling)?boundless-author-7811
12/17/2025, 7:07 AMtall-airline-16756
12/17/2025, 7:07 AMcreate_sip_participant returns a SIPParticipantInfo (not an object with .participant), so access res.attributes directly instead of res.participant.attributes.
• get_participant returns a response with a participant field; that one has .attributes.
Runnable example (blocking with wait_until_answered=True)
from datetime import timedelta
async def create_with_wait(ctx, room_name, trunk_id, dial_number, participant_identity):
try:
res = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{participant_identity}",
max_call_duration=timedelta(seconds=180),
wait_until_answered=True
)
)
# res is SIPParticipantInfo; attributes are on res.attributes
call_status = (res.attributes or {}).get("sip.callStatus")
<http://ctx.logger.info|ctx.logger.info>("create returned, sip.callStatus=%s", call_status)
return {"status": "answered" if call_status == "active" else "unknown", "res": res}
except api.TwirpError as e:
sip_code = e.metadata.get("sip_status_code")
sip_msg = e.metadata.get("sip_status")
ctx.logger.error("create_sip_participant failed: %s %s", sip_code, sip_msg)
if sip_code == "486":
return {"status": "busy", "code": sip_code}
if sip_code == "487":
return {"status": "rejected", "code": sip_code}
if sip_code == "404":
return {"status": "not_found", "code": sip_code}
raise
If you want to double-check after return (sometimes attributes populate slightly later), call `get_participant`:
res2 = await ctx.api.room.get_participant(
api.RoomParticipantIdentity(room=room_name, identity=participant_identity)
)
call_status = (res2.participant.attributes or {}).get("sip.callStatus")
<http://ctx.logger.info|ctx.logger.info>("polled sip.callStatus=%s", call_status)
If you want, I can convert these snippets to fit exactly into your existing function and logging. Which one should I adapt — the blocking flow you posted or a polling loop after the blocking call?
Sources: Make outbound calls | LiveKit docs | SIP participant | LiveKit docsboundless-author-7811
12/17/2025, 7:52 AMtall-airline-16756
12/17/2025, 7:53 AMmax_call_duration does and what you can expect.
• Purpose: it sets the maximum allowed length of the SIP call. The timer starts once the call is answered (sip.callStatus == "active").
• Behavior: when the duration is reached, LiveKit automatically ends the call.
• Events emitted:
◦ The SIP participant will leave the room (you’ll get the normal participant-left/participant-removed event).
◦ A sip_call_ended webhook event is emitted with termination context (reason like max_call_duration_reached).
• Notes: handle cleanup on your participant-left handler and/or the webhook to log the reason or perform post-call actions.
If you want, I can show a small handler snippet that logs when a participant leaves and inspects the webhook payload for the max-duration reason.
Sources: server-sdk-js | livekit/livekit_composite | DeepWiki | SIP APIs | LiveKit docsboundless-author-7811
12/17/2025, 7:54 AMtall-airline-16756
12/17/2025, 7:54 AM# In your room/RTC event handlers
def on_participant_left(participant):
# participant is rtc.Participant (has .identity and .attributes)
identity = participant.identity
attrs = participant.attributes or {}
<http://ctx.logger.info|ctx.logger.info>("Participant left: %s, attrs=%s", identity, attrs)
# Some providers or LiveKit may include a reason attribute
reason = attrs.get("sip.call_end_reason") or attrs.get("call_end_reason")
if reason == "max_call_duration_reached":
<http://ctx.logger.info|ctx.logger.info>("Call ended due to max_call_duration for %s", identity)
else:
<http://ctx.logger.info|ctx.logger.info>("Participant %s left; reason=%s", identity, reason)
2) Webhook handler for sip_call_ended — HTTP endpoint that receives webhook payloads and handles max_call_duration_reached.
# Example Flask-like handler
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/webhook", methods=["POST"])
def webhook():
payload = request.get_json()
event = payload.get("event")
data = payload.get("data", {})
if event == "sip_call_ended":
sip_call_id = data.get("sip_call_id")
reason = data.get("reason") # e.g., "max_call_duration_reached"
room = data.get("room")
identity = data.get("participant_identity")
<http://app.logger.info|app.logger.info>("sip_call_ended: call=%s identity=%s reason=%s", sip_call_id, identity, reason)
if reason == "max_call_duration_reached":
# perform cleanup, logging, billing, or notify users
<http://app.logger.info|app.logger.info>("Call %s ended because max_call_duration was reached", sip_call_id)
# your post-call logic here
return jsonify({"ok": True})
Notes
• Use the in-process participant-left event for immediate cleanup in your app runtime.
• Use the sip_call_ended webhook for reliable server-side processing and for post-call analytics; it includes the termination reason like max_call_duration_reached.
• Add verification for webhook authenticity (signatures) per your webhook configuration.
Would you like these adapted to your framework (async aiohttp/FastAPI) or added logging/details?boundless-author-7811
12/17/2025, 7:55 AMtall-airline-16756
12/17/2025, 7:55 AMsip_call_ended events. Both detect the max_call_duration_reached termination and show where to perform cleanup/logging.
A — Async participant-left handler (in-app)
# adapt to your RTC client framework's event decorator/callback style
async def on_participant_left(participant):
"""
Called when a participant leaves the room.
`participant` is the runtime participant object with .identity and .attributes.
"""
identity = getattr(participant, "identity", "<unknown>")
attrs = getattr(participant, "attributes", {}) or {}
# Log basic info
<http://logger.info|logger.info>("Participant left: identity=%s, attrs=%s", identity, attrs)
# Look for a reason attribute signaling max duration
reason = attrs.get("sip.call_end_reason") or attrs.get("call_end_reason") or attrs.get("reason")
if reason == "max_call_duration_reached":
<http://logger.info|logger.info>("Call for %s ended due to max_call_duration", identity)
# perform cleanup: update DB, notify users, mark billing, etc.
await handle_max_duration_cleanup(identity, participant, attrs)
else:
<http://logger.info|logger.info>("Participant %s left (reason=%s)", identity, reason)
await handle_normal_cleanup(identity, participant, attrs)
async def handle_max_duration_cleanup(identity, participant, attrs):
# Implement your app-specific cleanup here
# Example: mark call record ended, increment counters, notify downstream systems
call_id = attrs.get("sip.call_id") or attrs.get("sip_call_id")
<http://logger.info|logger.info>("Handling max-duration cleanup for call=%s identity=%s", call_id, identity)
# ... your logic ...
Notes:
• Exact attribute keys may vary by integration; check logs for attribute names. Use the attribute keys you see in your environment.
• Register this handler with your LiveKit client/room event system (the wiring depends on your library).
B — FastAPI webhook handler for sip_call_ended
from fastapi import FastAPI, Request, HTTPException
import logging
app = FastAPI()
logger = logging.getLogger("sip_webhook")
# Optional: verify webhook signature here if configured
@app.post("/webhook")
async def webhook(request: Request):
payload = await request.json()
event = payload.get("event")
data = payload.get("data", {}) or {}
<http://logger.info|logger.info>("Received webhook event=%s data=%s", event, data)
if event == "sip_call_ended":
sip_call_id = data.get("sip_call_id") or data.get("call_id")
reason = data.get("reason") # e.g., "max_call_duration_reached"
room = data.get("room")
identity = data.get("participant_identity") or data.get("participant")
<http://logger.info|logger.info>("sip_call_ended: call=%s identity=%s reason=%s room=%s", sip_call_id, identity, reason, room)
if reason == "max_call_duration_reached":
# perform server-side post-call processing
await server_side_max_duration_tasks(sip_call_id, identity, room, data)
else:
# handle other end reasons
await server_side_regular_end(sip_call_id, identity, room, data)
# respond quickly to acknowledge
return {"ok": True}
async def server_side_max_duration_tasks(sip_call_id, identity, room, data):
<http://logger.info|logger.info>("Processing max duration end for call=%s identity=%s", sip_call_id, identity)
# e.g., update DB, mark billing, send notifications
# ... your logic ...
Deployment and verification tips
• Add webhook signature verification if you enabled signing on LiveKit webhooks.
• Log entire webhook payloads (safely) initially to confirm the exact fields your instance sends.
• Use both handlers: the in-app participant-left for immediate UX cleanup and the webhook for reliable server-side processing and analytics.
• If you don’t see the reason you expect, confirm max_call_duration was set on the SIP participant and check LiveKit server logs/webhook payloads for the exact field name.
Would you like me to adapt either handler to your specific framework (aiohttp, Quart, Django, or the exact LiveKit event wiring you use)?boundless-author-7811
12/17/2025, 7:58 AMtall-airline-16756
12/17/2025, 7:59 AM# Example assumes you have an async LiveKit room client object that emits events.
# Adjust to your SDK's actual event API (this matches common Python event callback patterns).
import asyncio
import logging
logger = logging.getLogger("livekit_handlers")
async def handle_max_duration_cleanup(identity, participant, attrs):
<http://logger.info|logger.info>("Handling max-duration cleanup for %s, attrs=%s", identity, attrs)
# your cleanup logic here (DB update, notify, billing, etc.)
async def on_participant_left(participant):
identity = getattr(participant, "identity", "<unknown>")
attrs = getattr(participant, "attributes", {}) or {}
<http://logger.info|logger.info>("Participant left: identity=%s attrs=%s", identity, attrs)
reason = attrs.get("sip.call_end_reason") or attrs.get("call_end_reason") or attrs.get("reason")
if reason == "max_call_duration_reached":
<http://logger.info|logger.info>("Call ended due to max_call_duration for %s", identity)
await handle_max_duration_cleanup(identity, participant, attrs)
else:
<http://logger.info|logger.info>("Participant %s left; reason=%s", identity, reason)
# normal cleanup
# Example registration: if your room exposes `on`/`add_listener` style API:
def register_handlers(room):
# common pattern: room.on(event_name, callback)
# replace with your SDK's equivalent registration method
room.on("participant_left", lambda p: asyncio.create_task(on_participant_left(p)))
room.on("participant_removed", lambda p: asyncio.create_task(on_participant_left(p)))
room.on("participant_attributes_changed", lambda changed, p: asyncio.create_task(on_attributes_changed(changed, p)))
# optional attributes-changed handler
async def on_attributes_changed(changed_attributes: dict, participant):
if "sip.callStatus" in changed_attributes:
status = changed_attributes["sip.callStatus"]
identity = getattr(participant, "identity", "<unknown>")
<http://logger.info|logger.info>("Participant %s sip.callStatus -> %s", identity, status)
if status == "active":
<http://logger.info|logger.info>("Call answered for %s", identity)
How to wire:
• Call register_handlers(your_room) after you obtain the room client (when your app joins or creates the room).
• Many Python SDKs require callbacks to be synchronous; wrapping in asyncio.create_task keeps your async handler running.
B — JavaScript / Node (LiveKit Room SDK)
// Example assumes `room` is a LiveKit Room instance from livekit-client (node/web)
// and uses EventEmitter style handlers.
const registerHandlers = (room) => {
// participant removed/left
room.on('participantDisconnected', (participant) => {
// participant is a RemoteParticipant/Participant object
const identity = participant.identity;
const attrs = participant.metadata ? JSON.parse(participant.metadata) : {}; // if metadata used
console.log('participantDisconnected', identity, attrs);
const reason = attrs['sip.call_end_reason'] || attrs['call_end_reason'] || attrs['reason'];
if (reason === 'max_call_duration_reached') {
console.log(`Call for ${identity} ended due to max_call_duration`);
// perform cleanup
}
});
// participant metadata/attributes changed (if SDK exposes this)
room.on('participantMetadataChanged', (participant) => {
// some SDKs surface attributes/metadata changes via this event
const identity = participant.identity;
const attrs = participant.metadata ? JSON.parse(participant.metadata) : {};
const status = attrs['sip.callStatus'];
if (status) {
console.log(`Participant ${identity} sip.callStatus -> ${status}`);
if (status === 'active') {
console.log(`Call answered for ${identity}`);
}
}
});
// for livekit-client browser SDK, `participantDisconnected` and `trackPublished` events are common.
};
module.exports = { registerHandlers };
Notes and adjustments
• Exact event names vary by SDK/version: common names include participant_left, participant_removed, participantDisconnected, participantDisconnected, participant_metadata_changed, or participant_attributes_changed. If an event name differs in your library, use its documented name.
• Participant attributes may live in participant.attributes, participant.metadata, or be provided as the changed_attributes map on an attributes-changed event. Inspect the participant object logged at runtime to confirm keys.
• Wrap async handlers with asyncio.create_task (Python) or ensure Node handlers call async functions properly (no unhandled promises).
• Keep webhook handling (server-side) as the reliable source of termination reason if you need guaranteed processing.
If you share which SDK and version you’re using (Python server SDK, livekit-js in Node, or browser), I’ll adapt this snippet exactly to that library’s event API.boundless-author-7811
12/17/2025, 11:05 AMtall-airline-16756
12/17/2025, 11:05 AMrtc.LocalParticipant (local user) in LiveKit:
• Identity and IDs
◦ identity — developer-provided identity string
◦ sid — server-assigned participant SID
• Display / descriptive info
◦ name — display name
◦ metadata — arbitrary string you can get/set
◦ attributes — key/value map you can get/set
• Connection & type
◦ state — connection state (JOINING, JOINED, ACTIVE, DISCONNECTED)
◦ kind / kind_detail — participant kind (STANDARD, SIP, AGENT, etc.)
• Tracks & publications
◦ trackPublications / audioTrackPublications / videoTrackPublications — published tracks and their TrackPublication objects
◦ Each publication has sid, name, kind, source, and muted status
◦ Methods to publish/unpublish tracks (e.g., publishTrack, publishAudioTrack)
• Permissions & subscription control
◦ permissions / ParticipantPermission — what the participant may do
◦ APIs to set subscription permissions (who can subscribe to your tracks)
• Data & messaging
◦ publishData(...) — send data packets to other participants
• Timestamps and lifecycle
◦ joined_at / connection timestamps
• Methods to update local state
◦ setMetadata(metadata) — update metadata
◦ setAttributes(attributes) — update attributes
◦ setTrackSubscriptionPermissions(...) — control subscriptions
• Events you can observe for the local participant
◦ Track publication changes, metadata/attributes changes, permission updates, and connection state transitions
Key distinctions
• LocalParticipant represents the current client and exposes publishing/data APIs and setters; RemoteParticipant represents others and is used to subscribe to their tracks and read their metadata.
• Use event listeners to react to changes (metadata/attributes/track state/etc.).
If you tell me which SDK and language you’re using (e.g., livekit-js in Node/browser, Python server SDK, or mobile SDK), I’ll provide exact property names and a short code example for that environment.
Sources: Rooms, participants, and tracks | LiveKit docs | Participant attributes and metadata | LiveKit docs | Client SDKs | livekit/livekit_composite | DeepWiki | client-sdk-js | livekit/livekit_composite | DeepWiki | Data packets | LiveKit docsboundless-author-7811
12/17/2025, 11:11 AMtall-airline-16756
12/17/2025, 11:12 AM<http://self.logger.info|self.logger.info>("created sip participant identity=%s result=%s", participant_identity, participant)
• Ensure participant_identity is exactly the string you expect (unique per call).
2) Ensure you did NOT set hide_phone_number=True
• If hide_phone_number=True, LiveKit may generate an anonymized identity. Make sure your request either omits that field or sets it to False.
3) Wait for the participant to join before relying on events
• The participant may not be fully present when you attach/compare. Wait for the participant to appear, then register the listener:
# after creation
await ctx.wait_for_participant(identity=participant_identity)
register_attribute_listener()
4) Inspect the actual event payload (log everything)
• Temporarily log every attribute-change event to see who triggers it:
def on_participant_attributes_changed(changed_attrs, participant):
<http://self.logger.info|self.logger.info>("attr-changed event: identity=%s attrs=%s", getattr(participant,"identity","<nil>"), changed_attrs)
...
• This reveals the identity string and keys you should compare.
5) Compare the correct field
• Use participant.identity (not participant.sid or participant.participant_identity). Example:
if getattr(participant,"identity",None) != target_participant_identity:
return
# handle changes for the target participant
6) Avoid race conditions
• If you create multiple SIP participants, events for other participants will arrive too. Use unique identities (e.g., include call_id) so comparisons are unambiguous.
If you want, paste:
• the exact CreateSIPParticipantRequest you send (including any optional fields), and
• one full log line printed from the attribute-change handler
and I’ll point out the exact mismatch and a minimal code change.
Sources: Make outbound calls | LiveKit docs | Participant attributes and metadata | LiveKit docs | SIP APIs | LiveKit docs | Agent-assisted warm transfer | LiveKit docs | Rooms, participants, and tracks | LiveKit docsboundless-author-7811
12/17/2025, 11:14 AMtall-airline-16756
12/17/2025, 11:14 AMCreateSIPParticipantRequest looks correct (no hide_phone_number shown). Next steps to find why the identity in your on_participant_attributes_changed callback doesn't match the participant_identity you passed:
1) Log the create result immediately
<http://self.logger.info|self.logger.info>("create_sip_participant returned: %s", participant)
Check the returned object for the identity string (it’s participant.participant_identity / participant.identity depending on client). Confirm it exactly equals participant_identity.
2) Log every attributes-changed event (no filter) to see the actual identity delivered
def on_participant_attributes_changed(changed_attrs, participant):
<http://self.logger.info|self.logger.info>("attr-changed event: identity=%s attrs=%s", getattr(participant, "identity", None), changed_attrs)
This reveals whether the event is for a different participant or the identity string is different (extra whitespace, prefix, etc.).
3) Wait for participant join before comparing (avoid race)
• After create, wait for the participant to appear, then attach listener or start checks:
await ctx.wait_for_participant(identity=participant_identity)
# then rely on on_participant_attributes_changed comparisons
4) Compare the correct field and normalize strings
• Use participant.identity (or participant.participant_identity if your returned object uses that).
• Normalize both sides:
if (getattr(participant,"identity","").strip() != participant_identity.strip()):
return
5) Ensure uniqueness to avoid collisions
• Use a unique identity per call (include call_id) to avoid matching a different session.
6) If still mismatched, capture exact values and paste here
• Add this temp log right after creation and in the handler, then share the two lines:
◦ create log: "create returned identity=..."
◦ handler log: "attr-changed event identity=..."
I’ll inspect and point out the exact mismatch and minimal fix.
Which log lines can you paste here (create result and one sample attr-changed event)?boundless-author-7811
12/17/2025, 11:21 AMtall-airline-16756
12/17/2025, 11:21 AMsid is unique for that participant instance and is safe to use as an unambiguous identifier within LiveKit.
More detail:
• Purpose: sid is generated by LiveKit and used internally and by APIs to reference a specific resource regardless of developer-provided identity or name.
• Uniqueness scope: each sid is globally unique for that resource type within the LiveKit deployment (you won’t get two participants with the same participant sid).
• When to use sid vs `identity`:
◦ Use sid when you need an immutable, server-side identifier (logs, DB keys, API calls that target a specific participant instance).
◦ Use identity when you want a human-meaningful or developer-provided label (login names, display mapping). Note that identities can be re-used across sessions and may be collided or replaced.
• Lifespan: a participant sid is tied to that connection/session. If the same logical user rejoins, they may get a new sid.
• Examples of use: finding the participant in room queries, correlating events/traces, or storing call records keyed to the server sid.
If you want, I can show how to log and compare sid in your handlers instead of identity, or provide a short snippet storing sid in your DB for later lookups. Which would you prefer?boundless-author-7811
12/17/2025, 11:30 AMdef on_participant_attributes_changed(changed_attrs, participant):
"""Track real-time attribute changes (call status, warnings, etc.)"""
<http://self.logger.info|self.logger.info>(f"$$$$$$$$$$ logging participant details in on_participant_attributes_changed:{participant}")
participant_sid = getattr(participant, "sid", "<unknown>")
<http://self.logger.info|self.logger.info>(f"%%%%%%%%% Logging participant_sid:{participant_sid} and target_participant_identity:{target_participant_identity} ")
# get participant details
if target_participant_identity and participant_sid != get_participant_res.sid:
return
# Log SIP call status changes
if "sip.callStatus" in changed_attrs:
status = changed_attrs["sip.callStatus"]
<http://self.logger.info|self.logger.info>(
"[%s] 📞 Call status changed for %s: %s",
room_name,
target_participant_identity,
status
)
if status == "active":
<http://self.logger.info|self.logger.info>("[%s] ✅ Call answered", room_name)
# Log any timeout warnings or end reasons
if "sip.call_end_reason" in changed_attrs:
reason = changed_attrs["sip.call_end_reason"]
self.logger.warning(
"[%s] ⚠️ Call end reason updated: %s",
room_name,
reason
)
# Log other relevant attribute changes
relevant_attrs = {k: v for k, v in changed_attrs.items()
if k.startswith("sip.") and k not in ["sip.callStatus", "sip.call_end_reason"]}
if relevant_attrs:
self.logger.debug(
"[%s] 🔄 SIP attributes changed for %s: %s",
room_name,
target_participant_identity,
relevant_attrs
)tall-airline-16756
12/17/2025, 11:30 AMsid to a known sid (not to get_participant_res.sid unless that variable is defined and in scope).
• If you want to target by identity, compare participant.identity to target_participant_identity.
• Normalize and log the actual values to avoid surprises.
Corrected handler (minimal changes):
def on_participant_attributes_changed(changed_attrs, participant):
"""Track real-time attribute changes (call status, warnings, etc.)"""
<http://self.logger.info|self.logger.info>("$$$$$$$$$$ logging participant details in on_participant_attributes_changed: %s", participant)
participant_sid = getattr(participant, "sid", "<unknown>")
participant_identity = getattr(participant, "identity", None)
<http://self.logger.info|self.logger.info>("%%%%%%%%% Logging participant_sid:%s identity:%s target_participant_identity:%s",
participant_sid, participant_identity, target_participant_identity)
# If you are matching by SID, ensure you have stored target_participant_sid earlier:
if target_participant_sid and participant_sid != target_participant_sid:
return
# Or, if matching by identity:
if target_participant_identity and participant_identity != target_participant_identity:
return
# Log SIP call status changes
if "sip.callStatus" in changed_attrs:
status = changed_attrs["sip.callStatus"]
<http://self.logger.info|self.logger.info>("[%s] 📞 Call status changed for %s: %s",
room_name, participant_identity or participant_sid, status)
if status == "active":
<http://self.logger.info|self.logger.info>("[%s] ✅ Call answered", room_name)
# Log any timeout warnings or end reasons
if "sip.call_end_reason" in changed_attrs:
reason = changed_attrs["sip.call_end_reason"]
self.logger.warning("[%s] ⚠️ Call end reason updated for %s: %s",
room_name, participant_identity or participant_sid, reason)
# Log other relevant attribute changes
relevant_attrs = {k: v for k, v in changed_attrs.items()
if k.startswith("sip.") and k not in ["sip.callStatus", "sip.call_end_reason"]}
if relevant_attrs:
self.logger.debug("[%s] 🔄 SIP attributes changed for %s: %s",
room_name, participant_identity or participant_sid, relevant_attrs)
If you don't yet have target_participant_sid, capture it when you create the SIP participant:
res = await ctx.api.sip.create_sip_participant(...)
target_participant_sid = getattr(res, "participant_id", None) or getattr(res, "sid", None)
Want me to adapt the handler to only use SID (recommended) and show exactly where to store target_participant_sid after creation?boundless-author-7811
12/17/2025, 11:47 AMtall-airline-16756
12/17/2025, 11:47 AM<http://self.logger.info|self.logger.info>("attr-changed: kind=%s sid=%s identity=%s attrs=%s",
getattr(participant, "kind", None),
getattr(participant, "sid", None),
getattr(participant, "identity", None),
changed_attrs)
How to ensure you only handle the SIP participant
• Filter by kind:
if getattr(participant, "kind", "").upper() != "SIP":
return
• Or filter by the server-assigned SID you saved when creating the SIP participant:
# store target_participant_sid after create_sip_participant (res.participant_id or res.participant_sid)
if target_participant_sid and getattr(participant, "sid", None) != target_participant_sid:
return
• Or filter by identity but ensure you compare normalized strings and wait for the SIP participant to join first:
await ctx.wait_for_participant(identity=participant_identity)
# then attach listener or start checking events
Recommended minimal corrected handler
def on_participant_attributes_changed(changed_attrs, participant):
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://self.logger.info|self.logger.info>("attr-changed: kind=%s sid=%s identity=%s attrs=%s",
kind, sid, identity, changed_attrs)
# only care about SIP participants (or use saved SID)
if kind != "SIP":
return
# if you have stored the SIP participant sid, match that instead:
if target_participant_sid and sid != target_participant_sid:
return
# handle sip.callStatus etc...
Action items to try now
1. After create, log the creation response and save the returned SID:
◦ res = await ctx.api.sip.create_sip_participant(...)
◦ save target_participant_sid = getattr(res, "participant_id", None) or getattr(res, "participant_sid", None) or getattr(res, "sid", None)
1. Use the handler above to filter by kind == "SIP" or by target_participant_sid.
2. If you still see the local participant, paste the logs from the create response and one attr-changed log line and I’ll point out the mismatch.
Want me to produce the exact patch to your handler that saves the SID on creation and uses it to filter?boundless-author-7811
12/17/2025, 11:51 AMtall-airline-16756
12/17/2025, 11:51 AMfrom livekit.api import RoomParticipantIdentity
res = await ctx.api.room.get_participant(
RoomParticipantIdentity(room=room_name, identity=participant_identity)
)
participant = res.participant
# access fields:
sid = participant.sid
identity = participant.identity
kind = participant.kind
attrs = participant.attributes or {}
Node (server-side RoomServiceClient):
const res = await roomService.getParticipant(roomName, participantIdentity);
const participant = res.participant;
const sid = participant.sid;
const identity = participant.identity;
const attrs = participant.attributes || {};
Go:
res, err := roomClient.GetParticipant(ctx, &livekit.RoomParticipantIdentity{
Room: roomName, Identity: participantIdentity,
})
participant := res.Participant
sid := participant.Sid
Notes:
• You must call this from server-side code with appropriate roomAdmin privileges.
• Response includes sid, identity, state, metadata, attributes, tracks, permission, kind.
• Use participant.sid as the immutable identifier; identity is developer-provided and may be reused.
• If you need to poll call state, read participant.attributes["sip.callStatus"].
• If GetParticipant returns no participant, wait for join or ensure identity is correct/unique.
Tell me which SDK/language you want a fuller runnable snippet for and I’ll provide it.
Sources: Rooms, participants, and tracks | LiveKit docs | Managing participants | LiveKit docsboundless-author-7811
12/17/2025, 11:56 AMon_participant_attributes_changed:rtc.LocalParticipant(sid=PA_Mvisbiasdasd, identity=agent-AJ_CU7jHdaZm5F, name=)"
i am creating the participant like the following:
participant = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{name}",
max_call_duration=timedelta(seconds=180), # 3 minutes max
wait_until_answered=True
)
)
is any event fired after the call has gone beyond 3 minutestall-airline-16756
12/17/2025, 11:57 AMsip.call_end_reason or similar).
• A sip_call_ended webhook is also emitted with reason like max_call_duration_reached.
Immediate fixes (minimal changes)
1. Log identifying fields in your handler to confirm which participant you got:
<http://self.logger.info|self.logger.info>("attr-changed: kind=%s sid=%s identity=%s attrs=%s",
getattr(participant,"kind",None),
getattr(participant,"sid",None),
getattr(participant,"identity",None),
changed_attrs)
1. Filter for the SIP participant (recommended) or by saved SID:
# prefer SID if you saved it after creation
if target_participant_sid and getattr(participant,"sid",None) != target_participant_sid:
return
# or filter by kind
if getattr(participant,"kind","").upper() != "SIP":
return
1. Save the SIP participant SID immediately after creation:
res = await ctx.api.sip.create_sip_participant(...)
# res is SIPParticipantInfo — extract server id
target_participant_sid = getattr(res, "participant_id", None) or getattr(res, "participant_sid", None) or getattr(res, "participant_id", None)
1. Handle max duration in your handlers:
• In the attributes-changed handler, check sip.call_end_reason or sip.callStatus == "hangup".
• Also handle participant-left/participant-removed to run cleanup.
• Use the webhook sip_call_ended for reliable server-side processing (includes reason).
If you want, I can:
• Patch your handler to save the SID at creation and use it to filter events, or
• Provide a short runnable example that demonstrates creating the SIP participant, saving the returned SID, and reacting only to that SIP participant’s attribute changes and leave events. Which do you prefer?boundless-author-7811
12/17/2025, 11:58 AMtall-airline-16756
12/17/2025, 11:58 AMwait_until_answered=True
• Saves the returned SIP participant SID
• Registers handlers that filter events by that SID and by kind == "SIP"
• Reacts to sip.callStatus changes and participant-left events (including max duration termination)
Adjust names for your LiveKit client objects and event wiring.
import asyncio
from datetime import timedelta
# Replace these with your actual LiveKit API client/context objects
# ctx.api.sip.create_sip_participant(...) and ctx.api.room.get_participant(...) are used below.
# Also replace `room_client` / `room` with your runtime room object that emits events.
POLL_TIMEOUT = 30
async def create_sip_and_listen(ctx, room, room_name, trunk_id, dial_number, participant_identity, customer_name):
# store target SID in outer scope so handlers can access it
target_participant_sid = None
# Handler: attribute changes
def on_participant_attributes_changed(changed_attrs, participant):
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
# Debug log
<http://ctx.logger.info|ctx.logger.info>("attr-changed event: kind=%s sid=%s identity=%s changed=%s",
kind, sid, identity, changed_attrs)
# Only handle SIP participant with matching SID
if kind != "SIP":
return
if target_participant_sid is None or sid != target_participant_sid:
return
# react to sip.callStatus changes
if "sip.callStatus" in changed_attrs:
status = changed_attrs["sip.callStatus"]
<http://ctx.logger.info|ctx.logger.info>("SIP callStatus for %s (%s) -> %s", identity or sid, sid, status)
if status == "active":
<http://ctx.logger.info|ctx.logger.info>("Call answered for %s", identity or sid)
if status == "hangup":
<http://ctx.logger.info|ctx.logger.info>("Call ended (hangup) for %s", identity or sid)
# react to explicit end reasons
if "sip.call_end_reason" in changed_attrs:
reason = changed_attrs["sip.call_end_reason"]
ctx.logger.warning("Call end reason for %s: %s", identity or sid, reason)
# Handler: participant left / removed
def on_participant_left(participant):
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("participant left event: kind=%s sid=%s identity=%s", kind, sid, identity)
if kind != "SIP":
return
if target_participant_sid is None or sid != target_participant_sid:
return
<http://ctx.logger.info|ctx.logger.info>("Target SIP participant left: %s (%s)", identity or sid, sid)
# perform cleanup here (DB updates, notify, etc.)
# Register handlers on the room (adjust to your SDK's registration API)
# Wrap in tasks if the SDK expects sync callbacks
room.on("participant_attributes_changed", lambda changed, p: asyncio.create_task(async_wrap(on_participant_attributes_changed, changed, p)))
room.on("participant_left", lambda p: asyncio.create_task(async_wrap(on_participant_left, p)))
room.on("participant_removed", lambda p: asyncio.create_task(async_wrap(on_participant_left, p)))
# Helper to allow sync handler code to run as async task
async def async_wrap(func, *args, **kwargs):
return func(*args, **kwargs)
# Create SIP participant (blocking until answered). Capture returned SID from response.
try:
res = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{customer_name}",
max_call_duration=timedelta(seconds=180),
wait_until_answered=True,
)
)
except api.TwirpError as e:
ctx.logger.error("Failed to create SIP participant: %s metadata=%s", e, getattr(e, "metadata", None))
raise
# The create response is SIPParticipantInfo — extract the server participant id (SID)
# Field names may vary by client; check returned object for participant_id/participant_id or sid fields
target_participant_sid = getattr(res, "participant_id", None) or getattr(res, "participant_sid", None) or getattr(res, "sid", None) or getattr(res, "participant", None) and getattr(res.participant, "sid", None)
<http://ctx.logger.info|ctx.logger.info>("Created SIP participant: identity=%s returned_res=%s target_sid=%s", participant_identity, res, target_participant_sid)
# Optionally: poll once to confirm attributes
# (use RoomService GetParticipant if you have room admin access)
try:
get_res = await ctx.api.room.get_participant(api.RoomParticipantIdentity(room=room_name, identity=participant_identity))
p = get_res.participant
attrs = p.attributes or {}
<http://ctx.logger.info|ctx.logger.info>("Polled participant attrs after create: sid=%s identity=%s attrs=%s", getattr(p, "sid", None), getattr(p, "identity", None), attrs)
except Exception:
# get_participant may fail if not yet joined; that's OK
<http://ctx.logger.info|ctx.logger.info>("Could not poll participant immediately; waiting for events")
# Keep the function alive or return control to your app; handlers will react to changes while running
return {"participant_identity": participant_identity, "participant_sid": target_participant_sid}
Notes and tips
• Ensure your room.on(...) uses the correct event names for your SDK (some SDKs name them slightly differently). Replace string names with SDK enums if available.
• If your SDK's event callbacks are synchronous, wrapping them with asyncio.create_task prevents blocking the event loop.
• res field names vary by language client — log the full res object to confirm which property holds the SIP participant SID (common names: participant_id, participant_sid, or nested res.participant.sid).
• Filtering by sid and kind == "SIP" avoids handling local participant events.
If you want, I can adapt this example to your exact SDK (livekit-python, livekit-server-sdk, or livekit-js) and show the precise registration calls for that library. Which SDK are you using?boundless-author-7811
12/17/2025, 12:03 PMtall-airline-16756
12/17/2025, 12:04 PMwait_until_answered=True
• Extracts and saves the returned participant SID
• Registers room event handlers that filter by kind == "SIP" and the saved SID
• Reacts to sip.callStatus changes and participant leave events (including max duration termination)
Adjust imports, client initialization, and event-registration calls to match your environment.
import asyncio
from datetime import timedelta
from typing import Optional
# Replace these with your actual imports / client objects
# from livekit import api # your generated SDK API module
# ctx.api should expose ctx.api.sip.create_sip_participant and ctx.api.room.get_participant
# `room` should be your runtime room client that emits events.
POLL_TIMEOUT = 30.0 # seconds
async def run_sip_call(ctx, room, room_name: str, trunk_id: str, dial_number: str, participant_identity: str, customer_name: str):
# container for the target SIP participant SID (closure so handlers can read/write)
target_participant_sid: Optional[str] = None
# helper wrapper to run sync handler logic as async tasks
async def async_wrap(func, *args, **kwargs):
return func(*args, **kwargs)
# attribute-changed handler
def on_participant_attributes_changed(changed_attrs: dict, participant):
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("attr-changed event: kind=%s sid=%s identity=%s changed=%s",
kind, sid, identity, changed_attrs)
# only care about SIP participant with matching SID
if kind != "SIP":
return
if target_participant_sid is None or sid != target_participant_sid:
return
# react to sip.callStatus changes
if "sip.callStatus" in changed_attrs:
status = changed_attrs["sip.callStatus"]
<http://ctx.logger.info|ctx.logger.info>("SIP callStatus for %s (%s) -> %s", identity or sid, sid, status)
if status == "active":
<http://ctx.logger.info|ctx.logger.info>("Call answered for %s", identity or sid)
if status == "hangup":
<http://ctx.logger.info|ctx.logger.info>("Call ended (hangup) for %s", identity or sid)
# explicit end reason
if "sip.call_end_reason" in changed_attrs:
reason = changed_attrs["sip.call_end_reason"]
ctx.logger.warning("Call end reason for %s: %s", identity or sid, reason)
# participant-left / removed handler
def on_participant_left(participant):
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("participant left event: kind=%s sid=%s identity=%s", kind, sid, identity)
if kind != "SIP":
return
if target_participant_sid is None or sid != target_participant_sid:
return
<http://ctx.logger.info|ctx.logger.info>("Target SIP participant left: %s (%s)", identity or sid, sid)
# cleanup actions here (DB, notify, etc.)
# Register handlers on the room.
# Replace these with your SDK's exact registration calls if names differ.
room.on("participant_attributes_changed", lambda changed, p: asyncio.create_task(async_wrap(on_participant_attributes_changed, changed, p)))
room.on("participant_left", lambda p: asyncio.create_task(async_wrap(on_participant_left, p)))
room.on("participant_removed", lambda p: asyncio.create_task(async_wrap(on_participant_left, p)))
# Create SIP participant (blocking until answered)
try:
res = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{customer_name}",
max_call_duration=timedelta(seconds=180),
wait_until_answered=True,
)
)
except api.TwirpError as e:
ctx.logger.error("Failed to create SIP participant: %s metadata=%s", e, getattr(e, "metadata", None))
raise
# Extract the server-assigned participant SID from the response.
# Field names vary by client; check the actual object you receive.
target_participant_sid = (
getattr(res, "participant_id", None)
or getattr(res, "participant_sid", None)
or getattr(res, "sid", None)
or (getattr(res, "participant", None) and getattr(res.participant, "sid", None))
)
<http://ctx.logger.info|ctx.logger.info>("Created SIP participant: identity=%s returned_res=%s target_sid=%s", participant_identity, res, target_participant_sid)
# Optionally poll once to confirm attributes (may not be immediately available)
try:
get_res = await ctx.api.room.get_participant(api.RoomParticipantIdentity(room=room_name, identity=participant_identity))
p = get_res.participant
attrs = p.attributes or {}
<http://ctx.logger.info|ctx.logger.info>("Polled participant attrs after create: sid=%s identity=%s attrs=%s", getattr(p, "sid", None), getattr(p, "identity", None), attrs)
except Exception:
<http://ctx.logger.info|ctx.logger.info>("Could not poll participant immediately; waiting for events")
# Keep the coroutine alive while events are handled by the registered callbacks.
# In a real app you'd return control; here we sleep/poll until SIP participant leaves or timeout.
timeout = 300.0
start = asyncio.get_event_loop().time()
while True:
if asyncio.get_event_loop().time() - start > timeout:
ctx.logger.warning("Timeout waiting for SIP lifecycle events")
break
await asyncio.sleep(1)
return {"participant_identity": participant_identity, "participant_sid": target_participant_sid}
Notes
• Replace room.on(...) event names if your Python client uses different names or enums.
• Log the full res object once to confirm which property contains the participant SID; adapt extraction accordingly.
• Filtering by sid + kind == "SIP" avoids processing local participant events.
• When max_call_duration is reached you will see either:
◦ sip.callStatus == "hangup" or a sip.call_end_reason attribute update in the attribute-changed handler, and
◦ a participant_left / participant_removed event for the SIP participant, and
◦ a sip_call_ended webhook if you have webhooks enabled.
If you want, I’ll adapt this to the exact livekit-python package API you have (show me a sample res printed after create, and I’ll pin the correct sid extraction line).boundless-author-7811
12/17/2025, 12:22 PMtall-airline-16756
12/17/2025, 12:23 PMif getattr(participant, "kind", "").upper() != "SIP":
return
• By SID (recommended, immutable):
if target_participant_sid and getattr(participant, "sid", None) != target_participant_sid:
return
• Log events initially (no filter) to confirm what the SDK emits:
<http://self.logger.info|self.logger.info>("attr-changed: kind=%s sid=%s identity=%s attrs=%s",
getattr(participant,"kind",None),
getattr(participant,"sid",None),
getattr(participant,"identity",None),
changed_attrs)
• Register handlers after the SIP participant joins (or wait for it) to avoid processing early local-participant lifecycle events.
Would you like a tiny patch to your handler that applies these filters?boundless-author-7811
12/17/2025, 12:24 PMdef on_participant_attributes_changed(changed_attrs, participant):
"""Track real-time attribute changes (call status, warnings, etc.)"""
# Only process RemoteParticipant events (SIP callers), skip LocalParticipant (agent)
if not isinstance(participant, rtc.RemoteParticipant):
return
participant_sid = getattr(participant, "sid", "<unknown>")
# Only log for target participant
if target_participant_identity and participant_sid != get_participant_res.sid:
return
<http://self.logger.info|self.logger.info>(
"[%s] 🔄 SIP participant attributes changed: %s",
room_name,
{k: v for k, v in changed_attrs.items() if k.startswith("sip.")}
)
# Log SIP call status changes
if "sip.callStatus" in changed_attrs:
status = changed_attrs["sip.callStatus"]
<http://self.logger.info|self.logger.info>(
"[%s] 📞 Call status changed for %s: %s",
room_name,
target_participant_identity,
status
)
if status == "active":
<http://self.logger.info|self.logger.info>("[%s] ✅ Call answered", room_name)
# Log any timeout warnings or end reasons
if "sip.call_end_reason" in changed_attrs:
reason = changed_attrs["sip.call_end_reason"]
self.logger.warning(
"[%s] ⚠️ Call end reason updated: %s",
room_name,
reason
)
# Log other relevant attribute changes
relevant_attrs = {k: v for k, v in changed_attrs.items()
if k.startswith("sip.") and k not in ["sip.callStatus", "sip.call_end_reason"]}
if relevant_attrs:
self.logger.debug(
"[%s] 🔄 SIP attributes changed for %s: %s",
room_name,
target_participant_identity,
relevant_attrs
)
when i applied the above i did not see any logs because the particiapnt is local and not remotetall-airline-16756
12/17/2025, 12:25 PMsid. 3) In participant_attributes_changed and participant_left handlers, ignore LocalParticipant and only process events where kind == "SIP" and sid == target_sid.
Concise example (merge into your async app; adapt room.on registration to your SDK):
# assume ctx, room, room_name, participant_identity already defined
target_participant_sid = None
def on_participant_connected(participant):
nonlocal target_participant_sid
kind = getattr(participant, "kind", "").upper()
identity = getattr(participant, "identity", None)
sid = getattr(participant, "sid", None)
<http://ctx.logger.info|ctx.logger.info>("connected: kind=%s identity=%s sid=%s", kind, identity, sid)
if kind == "SIP" and identity == participant_identity:
target_participant_sid = sid
<http://ctx.logger.info|ctx.logger.info>("Saved target SIP participant SID=%s", target_participant_sid)
def on_participant_attributes_changed(changed_attrs, participant):
# ignore local participant
if isinstance(participant, rtc.LocalParticipant):
return
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("attr-changed: kind=%s identity=%s sid=%s changed=%s", kind, identity, sid, changed_attrs)
if kind != "SIP":
return
if target_participant_sid is None or sid != target_participant_sid:
return
# handle SIP attributes
if "sip.callStatus" in changed_attrs:
status = changed_attrs["sip.callStatus"]
<http://ctx.logger.info|ctx.logger.info>("sip.callStatus -> %s for %s", status, identity or sid)
if "sip.call_end_reason" in changed_attrs:
reason = changed_attrs["sip.call_end_reason"]
ctx.logger.warning("sip.call_end_reason -> %s for %s", reason, identity or sid)
def on_participant_left(participant):
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("left: kind=%s identity=%s sid=%s", kind, identity, sid)
if kind == "SIP" and sid == target_participant_sid:
<http://ctx.logger.info|ctx.logger.info>("Target SIP participant left: %s (%s)", identity or sid, sid)
# cleanup
# register handlers (wrap with asyncio.create_task if handlers must be async)
room.on("participant_connected", lambda p: on_participant_connected(p))
room.on("participant_attributes_changed", lambda changed, p: on_participant_attributes_changed(changed, p))
room.on("participant_left", lambda p: on_participant_left(p))
room.on("participant_removed", lambda p: on_participant_left(p))
# create SIP participant (example)
res = await ctx.api.sip.create_sip_participant(
api.CreateSIPParticipantRequest(
room_name=room_name,
sip_trunk_id=trunk_id,
sip_call_to=dial_number,
participant_identity=participant_identity,
participant_name=f"Customer_{name}",
max_call_duration=timedelta(seconds=180),
wait_until_answered=False, # non-blocking recommended so you can get connected event
)
)
<http://ctx.logger.info|ctx.logger.info>("create_sip_participant returned: %s", res)
Notes:
• Use wait_until_answered=False for non-blocking flow so you can capture the participant_connected event and SID. If you must use wait_until_answered=True, still capture the returned response and then poll or wait for participant_connected to get the SID.
• Filtering by sid and kind == "SIP" is the most reliable way to ignore local participant events.
• When max_call_duration elapses you will see sip.callStatus == "hangup", sip.call_end_reason (reason like max duration), and a participant_left/participant_removed event for the SIP participant; webhook sip_call_ended is also emitted if enabled.
If you want, I can adapt this exact pattern to your existing function (show me where you currently register handlers and whether you use wait_until_answered=True).boundless-author-7811
12/17/2025, 12:50 PM{"level": "ERROR", "name": "livekit", "message": "failed to emit event participant_disconnected\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.11/site-packages/livekit/rtc/event_emitter.py\", line 58, in emit\n callback(*callback_args)\n File \"/home/app-user/services/call_orch_service.py\", line 387, in on_participant_disconnected\n kind = getattr(participant, \"kind\", \"\").upper()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nAttributeError: 'int' object has no attribute 'upper'", "exc_info": "AttributeError: 'int' object has no attribute 'upper'", "pid": 94, "job_id": "AJ_AMkx4zxBUB4M", "room_id": "RM_Nevw9fqnQ7xb", "timestamp": "2025-12-17T12:41:48.786918+00:00"}tall-airline-16756
12/17/2025, 12:51 PMsession.on("participant_disconnected", on_participant_disconnected)
2) Defensively handle unexpected types to avoid crashing the emitter:
def on_participant_disconnected(participant):
# defensive: ensure we have a participant-like object
if not hasattr(participant, "kind"):
ctx.logger.warning("participant_disconnected received unexpected payload: %r", participant)
return
kind = getattr(participant, "kind", "").upper()
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("Participant disconnected: kind=%s sid=%s identity=%s", kind, sid, identity)
if kind != "SIP":
return
# handle SIP participant disconnect (cleanup, logging, etc.)
...
3) Log the raw event payload once to diagnose why an int is emitted:
ctx.logger.error("raw participant_disconnected payload: %r", participant)
If you paste that log line here I can help interpret it.
4) Common causes for non-object payloads:
• Wrong event name or emitter used elsewhere that emits different data.
• A wrapper or adapter that translates arguments differently.
• Bug in an older SDK version — consider upgrading the livekit package.
If you want, I can provide a full patched version of your disconnect handler (with SID-based filtering and cleanup) using your current handler code. Which do you prefer — the minimal defensive patch above or a full replacement?
Sources: Handling events | LiveKit docs | Rooms, participants, and tracks | LiveKit docsboundless-author-7811
12/17/2025, 1:45 PMtall-airline-16756
12/17/2025, 1:45 PMdef on_participant_disconnected(*args):
# normalize to get participant object if present
participant = None
for a in args:
# prefer objects that look like participants
if hasattr(a, "sid") or hasattr(a, "identity"):
participant = a
break
if participant is None:
ctx.logger.warning("participant_disconnected received unexpected args: %r", args)
return
kind = getattr(participant, "kind", "")
sid = getattr(participant, "sid", None)
identity = getattr(participant, "identity", None)
<http://ctx.logger.info|ctx.logger.info>("Participant disconnected: kind=%s sid=%s identity=%s", kind, sid, identity)
if str(kind).upper() != "SIP":
return
# handle SIP participant cleanup...
2) Prefer event names that consistently provide Participant objects
• Use participant_connected, participant_attributes_changed, and `participant_removed`/`participant_left` handlers where your SDK reliably passes the Participant object.
• If your SDK version is inconsistent, wrap all handlers with the defensive pattern above.
3) Log raw args once to diagnose
• Add a one-time log to capture the unexpected payload and determine which SDK version/adapter is emitting it:
ctx.logger.error("raw participant_disconnected payload: %r", args)
4) Short-term workaround vs long-term
• Workaround: use the defensive handler and filter by SID/kind as shown.
• Long-term: upgrade the livekit Python package to a version where event signatures are stable, or check the specific SIP repo issue discussion for the recommended fix.
If you want, paste one raw log line from that error (the args payload you log) and I’ll map exactly which argument is the participant vs reason and provide a fully patched handler you can drop in. Which log can you share?
Sources: Rooms, participants, and tracks | LiveKit docs | SIP participant | LiveKit docs | Participant attributes and metadata | LiveKit docs | SIP APIs | LiveKit docs | Create and configure a Plivo SIP trunk | LiveKit docs