alert-continent-21953
01/01/2026, 5:10 AMtall-airline-16756
01/01/2026, 5:10 AMtall-airline-16756
01/01/2026, 5:10 AMalert-continent-21953
01/01/2026, 5:12 AMtall-airline-16756
01/01/2026, 5:12 AMparticipant_joined (or participant_connected) for the SIP participant.
• Call end time: Record the timestamp from participant_left, participant_disconnected, or room_finished (whichever indicates the call ended).
• Duration: Calculate as end_time - start_time.
• Final status & end reason: Read the SIP participant attribute sip.callStatus (values include active, hangup, failed). Combine that with the leaving webhook/event to determine the final status and reason (e.g., sip.callStatus = hangup → participant hangup).
• Room-level termination: If room was explicitly deleted, room_finished or the delete_room action indicates a final end triggered by room deletion.
Recommended implementation (webhooks + per-participant state)
1. Enable webhooks for these events: participant_joined, `participant_left`/`participant_disconnected`, participant_attributes_changed, and room_finished.
2. On `participant_joined`:
◦ Save start_time and participant id (and mark participant kind = SIP if applicable).
1. On `participant_attributes_changed`:
◦ Watch for sip.callStatus updates; update the participant’s current status and store the latest value.
1. On participant_left or `participant_disconnected`:
◦ Save end_time, store the final sip.callStatus if present, compute duration = end_time - start_time, and persist a call record: {start_time, end_time, duration, final_status, end_reason}.
1. On `room_finished`:
◦ If you rely on room lifecycles, use this as a safeguard to finalize any in-progress calls and ensure records are closed.
1. Persist records in your DB/logging so you can query final summaries later.
Example logic sketch
• start_time = timestamp from participant_joined
• keep updating current_status from participant_attributes_changed (sip.callStatus)
• when participant_left or room_finished arrives:
◦ end_time = event.timestamp
◦ final_status = last known sip.callStatus (or derive from the leaving event)
◦ duration = end_time - start_time
◦ end_reason = map final_status to a reason (e.g., hangup → user hangup, failed → connection failure, room_finished → room deleted)
Questions to help me tailor exact code
• Which SDK/platform are you using (browser, iOS, Android, or server)?
• Do you prefer webhook-based server-side recording or client-side event reporting?
• Do you already have sip.callStatus available in your attributes for these calls?
Tell me the platform and preferred approach and I’ll provide a small code example you can drop in.
Sources: SIP Lifecycle Management Agent | LiveKit docs | Server-side | livekit/livekit_composite | DeepWiki | Agents telephony integration | LiveKit docsalert-continent-21953
01/01/2026, 10:23 AMtall-airline-16756
01/01/2026, 10:24 AMsip.callStatus on the SIP participant:
◦ If the first status you observe is ringing, treat the call as inbound.
◦ If the first status you observe is dialing, treat the call as outbound.
◦ Watch participant_attributes_changed webhooks/events to catch the initial status.
• If you create the SIP participant programmatically with create_sip_participant, that call is outbound by definition (since your server initiated it).
Which platform do you want a code example for (server webhook handler, browser SDK, Android, or iOS)?
Sources: SIP Lifecycle Management Agent | LiveKit docsalert-continent-21953
01/01/2026, 10:25 AMsip.callStatus on the SIP participant:
◦ If the first status you observe is ringing, treat the call as inbound.
◦ If the first status you observe is dialing, treat the call as outbound.
is this method reliable for production ?tall-airline-16756
01/01/2026, 10:25 AMsip.callStatus value to determine direction (first observed ringing = inbound, first observed dialing = outbound) is reliable for production, provided you follow a few best practices:
• Use event-driven detection: read the first sip.callStatus seen in participant_attributes_changed rather than polling the attribute at arbitrary times.
• Persist the first status timestamp so you don’t miss the initial state if later events arrive.
• Handle late-joining observers: if your agent joins after the initial states, fall back to known context (e.g., if you called create_sip_participant, mark that call outbound) or infer from current state plus metadata.
• Combine signals when possible: use the API path (outbound calls you create) and webhook events (participant_joined, participant_left, room_finished) alongside sip.callStatus to eliminate ambiguity.
• Map states to reasons and log them (e.g., ringing → inbound, dialing → outbound; `hangup`/`failed` → end reasons).
If you tell me your platform (server/webhook, browser, iOS, or Android) I’ll provide a concise code snippet to capture the first sip.callStatus and persist the direction reliably.
Sources: SIP Lifecycle Management Agent | LiveKit docs | Agents telephony integration | LiveKit docs | Accepting calls overview | LiveKit docs | examples/frontdesk | livekit/livekit_composite | DeepWikialert-continent-21953
01/01/2026, 11:10 AMtall-airline-16756
01/01/2026, 11:10 AMalert-continent-21953
01/01/2026, 11:10 AMtall-airline-16756
01/01/2026, 11:11 AMsip.callStatus (with fallbacks), captures start/end timestamps, computes duration, and persists/logs a final call record.
Before I send the code, one quick clarification so I tailor it exactly to your environment:
• Do you want persistence to a simple local SQLite DB or to an in-memory/log-only solution (you can switch to Postgres/Redis later)?
Tell me which persistence option you prefer and I’ll return the full Python file and run instructions.
Sources: SIP Lifecycle Management Agent | LiveKit docs | sip | livekit/livekit_composite | DeepWiki | Agents telephony integration | LiveKit docs | Outbound Calling Script | LiveKit docsalert-continent-21953
01/01/2026, 11:12 AMtall-airline-16756
01/01/2026, 11:13 AMsip_call_tracker.py.
import asyncio
import logging
import time
from datetime import datetime
from livekit.agents import AgentServer, JobContext, AgentSession, function_tool
from livekit import rtc
from livekit.agents.inference import STT, LLM, TTS
logging.basicConfig(level=<http://logging.INFO|logging.INFO>)
logger = logging.getLogger("sip-call-tracker")
class CallTracker:
def __init__(self, room_name: str):
self.room_name = room_name
self.direction = None # "inbound" or "outbound"
self.start_time = None
self.end_time = None
self.duration = None
self.last_status = None
self.phone_number = None
self.sip_call_id = None
self.trunk_id = None
self._first_status_recorded = False
def detect_direction_from_identity(self, identity: str):
# Simple identity heuristic: identities that start with "sip_" are inbound
if identity and identity.startswith("sip_"):
self.direction = "inbound"
else:
self.direction = "outbound"
<http://logger.info|logger.info>(f"Detected direction (identity heuristic): {self.direction}")
def record_first_status(self, status: str):
if not self._first_status_recorded and status:
# Use first observed sip.callStatus to determine direction if not already set
if status == "ringing":
self.direction = "inbound"
elif status == "dialing":
self.direction = "outbound"
self._first_status_recorded = True
<http://logger.info|logger.info>(f"First sip.callStatus observed: {status} -> direction: {self.direction}")
def update_status(self, status: str):
self.last_status = status
<http://logger.info|logger.info>(f"Call status update: {status}")
# Start time at first active
if status == "active" and not self.start_time:
self.start_time = time.time()
<http://logger.info|logger.info>(f"Call started at {datetime.fromtimestamp(self.start_time).isoformat()}")
def end_call(self):
self.end_time = time.time()
if self.start_time:
self.duration = self.end_time - self.start_time
<http://logger.info|logger.info>("Call ended.")
self.print_summary()
def print_summary(self):
summary = {
"room_name": self.room_name,
"direction": self.direction,
"phone_number": self.phone_number,
"trunk_id": self.trunk_id,
"sip_call_id": self.sip_call_id,
"started_at": datetime.fromtimestamp(self.start_time).isoformat() if self.start_time else None,
"ended_at": datetime.fromtimestamp(self.end_time).isoformat() if self.end_time else None,
"duration_seconds": round(self.duration, 2) if self.duration else None,
"final_status": self.last_status,
}
<http://logger.info|logger.info>("===== Final call summary =====")
for k, v in summary.items():
<http://logger.info|logger.info>(f"{k}: {v}")
<http://logger.info|logger.info>("==============================")
# In-memory map of trackers per room (single-process). Use DB/Redis for multi-worker.
trackers: dict[str, CallTracker] = {}
class SIPCallAgent:
def __init__(self, ctx: JobContext):
self.ctx = ctx
self.room_name = ctx.room.name
identity = ctx.participant.identity if ctx.participant else None
tracker = CallTracker(self.room_name)
tracker.detect_direction_from_identity(identity)
trackers[self.room_name] = tracker
self.tracker = tracker
async def on_participant_connected(self, participant: rtc.RemoteParticipant):
if participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
return
attrs = participant.attributes or {}
self.tracker.phone_number = attrs.get("sip.phoneNumber") or self.tracker.phone_number
self.tracker.sip_call_id = attrs.get("sip.callID") or self.tracker.sip_call_id
self.tracker.trunk_id = attrs.get("sip.trunkID") or self.tracker.trunk_id
# If sip.callStatus present on connect, record it (may be first status)
call_status = attrs.get("sip.callStatus")
if call_status:
self.tracker.record_first_status(call_status)
self.tracker.update_status(call_status)
<http://logger.info|logger.info>(f"SIP participant connected (room={self.room_name}, phone={self.tracker.phone_number})")
async def on_attributes_changed(self, changed: dict[str, str], participant: rtc.Participant):
if participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
return
if "sip.callStatus" in changed:
status = changed["sip.callStatus"]
self.tracker.record_first_status(status)
self.tracker.update_status(status)
if "sip.phoneNumber" in changed and not self.tracker.phone_number:
self.tracker.phone_number = changed["sip.phoneNumber"]
async def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
if participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
return
# Capture last status if provided on disconnect
attrs = participant.attributes or {}
final_status = attrs.get("sip.callStatus")
if final_status:
self.tracker.update_status(final_status)
self.tracker.end_call()
@function_tool()
async def get_call_info(ctx: any = None):
t = trackers.get(ctx.room.name)
if not t:
return {"error": "no call tracked"}
return {
"direction": t.direction,
"duration_seconds": round(t.duration, 2) if t.duration else None,
"status": t.last_status,
}
@AgentServer.rtc_session()
async def entrypoint(ctx: JobContext):
ctx.log_context_fields = {"room": ctx.room.name}
session = AgentSession(
stt=STT(model="deepgram/nova-3", language="en"),
llm=LLM(model="openai/gpt-4.1-mini"),
tts=TTS(model="elevenlabs/eleven_multilingual_v2"),
vad=None,
tools=[get_call_info],
)
agent = SIPCallAgent(ctx)
await session.start(agent=agent, room=ctx.room)
await ctx.connect(auto_subscribe=rtc.AutoSubscribe.AUDIO_ONLY)
ctx.room.on("participant_connected", lambda p: asyncio.create_task(agent.on_participant_connected(p)))
ctx.room.on("participant_attributes_changed", lambda changed, p: asyncio.create_task(agent.on_attributes_changed(changed, p)))
ctx.room.on("participant_disconnected", lambda p: asyncio.create_task(agent.on_participant_disconnected(p)))
<http://logger.info|logger.info>(f"Agent attached to room {ctx.room.name}")
server = AgentServer()
if __name__ == "__main__":
import sys
from livekit.agents.cli import run_app
run_app(server)
How to run
1. Install dependencies:
◦ pip install "livekit-agents[deepgram,openai,elevenlabs]" python-dotenv
1. Provide your LiveKit credentials via environment (.env) or environment variables as required by your LiveKit agent runtime.
2. Start agent:
◦ python sip_call_tracker.py console
◦ Or use `dev`/other run modes as your environment requires.
If you want, I can also:
• Add a small SQLite persistence snippet,
• Replace the identity heuristic with a metadata-based detection,
• Or give a tiny unit test to simulate attribute events. Which would you prefer next?