steep-balloon-41261
03/31/2026, 10:33 AMtall-airline-16756
03/31/2026, 10:33 AMtall-airline-16756
03/31/2026, 10:34 AMuser_input_transcribed events.
What to configure and why
• SIP / PSTN inbound
◦ Ensure your SIP trunk / dispatch rules route inbound calls to create or join a LiveKit room. PSTN callers will appear as SIP participants in that room.
◦ PSTN callers are treated like any other participant for audio input; the agent can listen to them by default.
• Agent session (STT-only)
◦ Start the agent session with audio input enabled and audio output disabled.
▪︎ This results in the agent not publishing an audio track and producing text-only output.
◦ When audio output is disabled, agent text responses are published to the lk.transcription text stream (they will not have lk.transcribed_track_id since no audio track exists).
◦ Use the STT provider you prefer (example: Deepgram) in your agent config so incoming audio is transcribed.
• Targeting the PSTN caller only
◦ By default agents listen to standard participants and SIP callers but not to other agents.
◦ To ensure the agent only processes audio from the PSTN caller (if there are other participants), configure RoomIO with the SIP participant identity or explicitly set the participant identity the agent should process.
◦ This ensures the agent ignores other room audio and transcribes only the selected SIP participant.
How to receive and display transcriptions
• Listen for the lk.transcription text stream topic on your frontend client to receive text segments published by the agent.
• Alternatively, handle the user_input_transcribed events in the agent runtime if you need server-side handling of final transcripts.
• Note: The built-in playground UI uses legacy transcription events and may not show text-only agent responses; implement the text-stream handler in your frontend to view these transcriptions.
Implementation pointers (high level)
1. Create or configure your SIP trunk/dispatch so inbound PSTN calls join the intended LiveKit room.
2. Start the agent session with:
◦ audio input enabled
◦ audio output disabled (so the agent is STT-only)
◦ configure the STT provider and transcription settings
1. Use RoomIO or the room participant identity to point the agent’s audio input at the SIP participant only.
2. On the client side, register a handler for lk.transcription to receive and display transcription segments. Check for the absence of lk.transcribed_track_id to identify text-only responses.
If you’d like, I can:
• Provide a minimal example (Node.js or Python) for starting the agent with audio_output disabled and receiving lk.transcription events.
• Show an example of how to configure RoomIO to target a specific participant identity (SIP caller).
• Walk through a sample SIP dispatch configuration to ensure calls arrive in the right room.
Which of these would help you next — a code example for starting the STT-only agent (Node or Python), or the RoomIO snippet to target the SIP participant?
Sources: How to build an agent with speech-to-text input and text-only output | Live... | sip | livekit/livekit_composite | DeepWiki | Transcriber | LiveKit Documentation, with interactive elementsmelodic-byte-50391
03/31/2026, 10:41 AMtall-airline-16756
03/31/2026, 10:41 AM// Example Node.js snippet showing RoomIO targeting a SIP participant identity
const { RoomIO } = require('livekit-agent-sdk'); // illustrative import
async function startAgentForSipParticipant(roomClient, sipParticipantIdentity) {
const roomIO = new RoomIO({
// Specify the participant identity to listen to (SIP caller)
participantIdentity: sipParticipantIdentity,
// Keep audio input enabled so the agent hears the participant
audioInput: true,
// Disable agent audio output to make the agent STT-only
audioOutput: false,
// (Optional) STT provider config goes here
stt: {
provider: 'deepgram',
apiKey: process.env.DG_API_KEY,
},
});
// Connect RoomIO to the live room client / agent runtime
await roomClient.attachRoomIO(roomIO);
// Now the agent will process audio only from the participant identified by sipParticipantIdentity
}
Python RoomIO snippet
# Example Python snippet showing RoomIO targeting a SIP participant identity
from livekit_agent_sdk import RoomIO # illustrative import
def start_agent_for_sip_participant(room_client, sip_participant_identity):
room_io = RoomIO(
participant_identity=sip_participant_identity,
audio_input=True, # listen for audio
audio_output=False, # STT-only: no audio published by agent
stt={
"provider": "deepgram",
"api_key": os.environ.get("DG_API_KEY"),
},
)
# Attach RoomIO to the room client / agent runtime
room_client.attach_room_io(room_io)
# Agent now listens only to the specified SIP participant
How to get the SIP participant identity
• When a PSTN caller joins via SIP, they appear in the LiveKit room as a participant with an identity you can read from the room’s participant list (participant.identity).
• You can:
◦ Inspect the participant list when the call arrives and capture the SIP participant identity.
◦ Use your SIP dispatch rules to set or predict the identity (for example, include a caller-specific identity when creating/joining the room).
Notes and tips
• Agents by default do not listen to other agents, but they do listen to SIP participants—explicitly targeting the SIP participant ensures other participants’ audio is ignored.
• Keep agent audio output disabled to publish text-only transcriptions to lk.transcription.
• Ensure your STT provider settings are configured in the agent so incoming audio is transcribed.
If you want, I can now:
• Provide a concrete example showing how to detect the SIP participant identity from the room participant list and pass it into RoomIO.
• Convert the snippets to match your exact agent SDK import names if you share which SDK (package) and language you’re using. Which would you prefer?, with interactive elementsmelodic-byte-50391
03/31/2026, 11:54 AMtall-airline-16756
03/31/2026, 11:55 AMlk.transcription (and/or emits user_input_transcribed server-side).
Important notes
• ACK webhooks quickly and process events asynchronously. Deduplicate using the webhook event id to avoid repeated work.
• Verify webhook authenticity using the WebhookReceiver / signature JWT in Authorization header.
• Agents by default don’t listen to other agents; targeting the SIP participant identity ensures the agent ignores unrelated audio.
• When audioOutput is disabled, transcriptions appear on lk.transcription and won’t include lk.transcribed_track_id.
Example: Node.js webhook handler (Express)
1. Handle and verify webhook, extract participant identity, then call your agent control endpoint.
// Express route (illustrative)
app.post('/webhook', async (req, res) => {
// 1) Verify webhook signature (use your WebhookReceiver equivalent)
// Quick ACK
res.sendStatus(200);
// 2) Process async
(async () => {
const event = req.body;
if (event.type !== 'participant_joined') return;
// Extract participant info
const participant = event.participant;
// Check for SIP-specific attributes (depends on payload shape)
if (!participant || !participant.identity) return;
// Optionally check participant.kind or sip fields if present
const isSip = participant.kind === 'sip' || (participant?.sip != null);
if (!isSip) return;
const sipIdentity = participant.identity;
const room = event.room;
// 3) Notify your agent runtime (internal API call)
await fetch(`${AGENT_CONTROL_URL}/start-listen`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ room, sipIdentity }),
});
})().catch(console.error);
});
Example: Python webhook handler (Flask)
from flask import Flask, request, jsonify
import requests
import threading
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
# Verify webhook signature here (WebhookReceiver.verify)
# Quick ACK
data = request.get_json()
threading.Thread(target=process_event, args=(data,)).start()
return ('', 200)
def process_event(event):
if event.get('type') != 'participant_joined':
return
p = event.get('participant', {})
# Detect SIP participant (payload may include p.get('sip') or kind)
if not p.get('identity') or not p.get('sip'):
return
sip_identity = p['identity']
room = event.get('room')
# Notify agent runtime
requests.post(f'{AGENT_CONTROL_URL}/start-listen', json={'room': room, 'sipIdentity': sip_identity})
Agent control endpoint (example)
• This is your backend API that the webhook calls. It tells the agent runtime to start listening to the SIP participant. Below is a minimal illustrative handler the agent runtime could expose.
Node.js agent runtime snippet (receives room + sipIdentity)
// POST /start-listen { room, sipIdentity }
app.post('/start-listen', async (req, res) => {
const { room, sipIdentity } = req.body;
// Start or update RoomIO for the running agent session in that room
// Example call into agent runtime:
await agentRuntime.attachRoomIO({
participantIdentity: sipIdentity,
audioInput: true,
audioOutput: false,
stt: { provider: 'deepgram', apiKey: process.env.DG_API_KEY },
});
res.json({ ok: true });
});
Python agent runtime snippet
# Flask handler inside your agent process
@app.route('/start-listen', methods=['POST'])
def start_listen():
payload = request.get_json()
sip_identity = payload['sipIdentity']
# Attach or update RoomIO in the agent runtime:
room_io = RoomIO(participant_identity=sip_identity,
audio_input=True,
audio_output=False,
stt={'provider':'deepgram', 'api_key':DG_API_KEY})
agent_runtime.attach_room_io(room_io)
return jsonify(ok=True)
How RoomIO targets the participant
• Setting participant_identity in RoomIO makes the agent subscribe to that participant’s audio only.
• Keep `audioOutput`/`audio_output` disabled so the agent will be STT-only and publish text to lk.transcription.
Detecting the participant identity reliably
• Participant identity is in the participant_joined webhook payload; you can also inspect the room participant list via the server SDK if needed.
• If your SIP dispatch can set a predictable identity or add metadata, use that to make matching simpler.
Edge cases & best practices
• If multiple SIP callers join, ensure your agent runtime can handle switching or multiple RoomIOs depending on the desired behavior (single-call focus vs. multi-call).
• Deduplicate webhook events by event id to avoid re-starting the same RoomIO repeatedly.
• If your agent process may not yet be in the room when the webhook fires, queue the instruction and attach RoomIO once the agent joins the room.
• If you need server-side final transcripts, listen for user_input_transcribed events in the agent runtime or consume lk.transcription text stream on the frontend.
If you’d like, I can now:
• Provide a concrete example that reads the actual participant_joined webhook payload schema and extracts the SIP fields (I can tailor this if you share a sample webhook payload).
• Show the exact RoomIO call in the SDK/language you use (if you tell me which agent SDK/package and language you’re running).
• Show how to handle multiple simultaneous SIP participants (queueing, switching, or multi-listen patterns).
Which would you prefer next — a payload-specific extraction example or a language-specific RoomIO call tailored to your agent SDK?
Sources: Best practices for managing webhook event streams | LiveKit | server-sdk-ruby | livekit/livekit_composite | DeepWiki | server-sdk-js | livekit/livekit_composite | DeepWiki | SIP Lifecycle Management Agent | LiveKit Documentation | Agents telephony integration | LiveKit Documentation, with interactive elementsmelodic-byte-50391
03/31/2026, 11:58 AMtall-airline-16756
03/31/2026, 11:58 AMparticipant.kind or participant.sip fields).
◦ Extract participant.identity and room.
◦ POST to your agent control endpoint: { room, sipIdentity }.
◦ Deduplicate by webhook event id to avoid repeated processing.
1. Agent control endpoint attaches/updates RoomIO (or ensures agent will listen to matching participant kinds). Use audioInput=true and audioOutput=false for STT-only.
2. Agent publishes transcriptions to lk.transcription (frontend) and/or emits user_input_transcribed (server-side).
Concise examples
1) Webhook handler (Node.js / Express — quick ACK, process async)
app.post('/webhook', (req, res) => {
// 1) Verify webhook signature (WebhookReceiver) -- omitted for brevity
res.sendStatus(200); // ACK quickly
// 2) Process asynchronously
(async () => {
const event = req.body;
if (event.type !== 'participant_joined') return;
const p = event.participant || {};
// Detect SIP participant (payload may include p.sip or p.kind)
const isSip = p.kind === 'sip' || !!p.sip;
if (!isSip || !p.identity) return;
const payload = { room: event.room, sipIdentity: p.identity, eventId: event.id };
// POST to your agent control endpoint
await fetch(`${AGENT_CONTROL_URL}/start-listen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
})().catch(console.error);
});
2) Webhook handler (Python / Flask — quick ACK, background thread)
from threading import Thread
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
# Verify signature here (WebhookReceiver) - omitted
data = request.get_json()
Thread(target=process_event, args=(data,)).start()
return ('', 200) # ACK quickly
def process_event(event):
if event.get('type') != 'participant_joined': return
p = event.get('participant', {})
is_sip = p.get('kind') == 'sip' or bool(p.get('sip'))
if not is_sip or not p.get('identity'): return
payload = {'room': event.get('room'), 'sipIdentity': p['identity'], 'eventId': event.get('id')}
requests.post(f'{AGENT_CONTROL_URL}/start-listen', json=payload)
3) Agent control endpoint (Node.js) — attach/update RoomIO for new sipIdentity
// POST /start-listen { room, sipIdentity }
app.post('/start-listen', async (req, res) => {
const { room, sipIdentity, eventId } = req.body;
// Optional: dedupe by eventId to avoid reprocessing
// Ensure agent runtime for `room` exists/connected
await agentRuntime.attachRoomIO({
participantIdentity: sipIdentity,
audioInput: true,
audioOutput: false, // STT-only
stt: { provider: 'deepgram', apiKey: process.env.DG_API_KEY },
});
res.json({ ok: true });
});
4) Agent control endpoint (Python) — same idea with RoomIO
@app.route('/start-listen', methods=['POST'])
def start_listen():
payload = request.get_json()
sip_identity = payload['sipIdentity']
# dedupe if needed using payload.get('eventId')
room_io = RoomIO(
participant_identity=sip_identity,
audio_input=True,
audio_output=False,
stt={'provider': 'deepgram', 'api_key': DG_API_KEY},
)
agent_runtime.attach_room_io(room_io)
return jsonify(ok=True)
Notes on multiple participants and defaults
• Agents already can process audio from multiple participants that match the configured participant_kinds. If your agent is configured to include SIP participants, new SIP participants may be processed automatically without any RoomIO changes.
• Explicitly attaching RoomIO with a participantIdentity guarantees the agent subscribes to that specific participant’s audio if you want strict control.
• If you want the agent to continue listening to existing callers plus new ones, use attach/update behavior (do not replace existing RoomIOs) or rely on default multi-participant subscription.
• Ensure you deduplicate events and handle the case where the agent isn’t yet connected to the room (queue the start-listen request or retry once agent joins).
Where I can help next
• I can produce a compact example that reads a real participant_joined payload you’ve received and extracts the SIP fields exactly.
• I can tailor the agent control call to your agent SDK/language (if you tell me which SDK and runtime you’re using).
• I can show patterns for handling multiple SIP callers concurrently (one RoomIO per caller vs letting the agent handle all matching participants).
Which would you like next — sample payload extraction or a language-specific RoomIO implementation matching your agent SDK?
Sources: SIP Lifecycle Management Agent | LiveKit Documentation | sip | livekit/livekit_composite | DeepWiki | Server-side | livekit/livekit_composite | DeepWiki | Best practices for managing webhook event streams | LiveKit | server-sdk-ruby | livekit/livekit_composite | DeepWiki, with interactive elements