Connect your LiveKit Agents to Tuner with a two-line SDK integration. Call data is automatically captured and sent to Tuner when each session ends.
This guide covers both the Python SDK (tuner-livekit-sdk) and the Node.js SDK (@usetuner.ai/livekit-sdk). Use the language switcher below to view the guide for your stack.
The Tuner LiveKit SDK automatically captures session data from your LiveKit agent and sends it to Tuner when the call ends — no manual API calls required. It ships in two flavours that share the same wire format and feature set: a Python package and a Node.js package.
1
Install the SDK
Add the package to your project.
2
Set Your Credentials
Configure your Tuner API key, workspace ID, and agent remote ID.
3
Add the Plugin
Drop two lines into your entrypoint and you’re done.
Use the Agent Remote ID from Agent Settings → Agent Connection — not your agent’s display name. It is a UUID (for example fa4da74c-...). Copy your value from the Tuner dashboard.
If you plan to run Call Simulation against this agent, the plugin needs one extra value: the SIP Call-ID of the inbound call. Tuner uses it to match the simulation call it just dialled with the session your agent syncs back, without it, simulated calls show up as ordinary production traffic.
This applies to inbound simulation, where Tuner dials your agent. For outbound simulation, pass the dialed SIP URI as recipient instead, see Outbound Simulation.
LiveKit attaches the SIP Call-ID to the SIP participant as the sip.callIDFull attribute. Read it after the room is connected and pass it to TunerPlugin as sip_correlation_id.
Requires tuner-livekit-sdk >= 0.1.5. The sip_correlation_id argument was added in 0.1.5.
The SIP participant only becomes visible after the agent joins the room. Call ctx.connect() first, then read the correlation ID, then pass it to TunerPlugin:
async def entrypoint(ctx: JobContext): await ctx.connect() # 1. connect first sip_correlation_id = _extract_sip_correlation_id(ctx) # 2. then read session = AgentSession(...) TunerPlugin( session, ctx, sip_correlation_id=sip_correlation_id, # 3. pass it in # ... your other TunerPlugin options ) await session.start(...)
If ctx.connect() runs after you try to read participants, ctx.room.remote_participants will be empty and sip_correlation_id will be None. Simulated calls will still complete, but they won’t be linked to their Tuner simulation rows.
Tuner captures why each call ended. User disconnects and errors are tracked automatically. To record agent-initiated hangups, pass reason="agent_hangup" to ctx.shutdown() when ending the call programmatically:
get_job_context().shutdown(reason="agent_hangup")
Without this, agent hangups are indistinguishable from dropped calls in your analytics.
Full tool call example
@function_toolasync def end_call(self, context: RunContext): """End the call once the user has been helped.""" await context.session.shutdown(drain=True) get_job_context().shutdown(reason="agent_hangup")
drain=True lets any in-flight audio finish before the session closes. Omit it for an immediate cutoff.
If your agent uses LangGraph or LangChain for its logic layer, you can capture node transitions, tool calls, and timing alongside the standard session data. The plugin handles all the wiring and flushes the events automatically when the session ends.
Save the plugin reference, then call wrap_graph() and pass the result into langchain.LLMAdapter. The handler records what the graph does on every invocation.
Tuner requires a recording_url for every call. Provide a resolver function that returns the URL. If you don’t provide one, the plugin submits "pending" as a placeholder.
Provide a callable that receives a UsageSummary and returns the call cost in USD cents. Implement the method that matches your pricing plan; the snippet below is illustrative.
Agent Remote ID from Agent Settings → Agent Connection (UUID — not your agent’s display name)
TUNER_BASE_URL
—
API base URL (default: https://api.usetuner.ai)
new TunerPlugin(session, ctx, { apiKey: 'tr_api_...', workspaceId: 123, agentId: 'fa4da74c-...',});
Use the Agent Remote ID from Agent Settings → Agent Connection — not your agent’s display name. It is a UUID (for example fa4da74c-...). Copy your value from the Tuner dashboard.
If you plan to run Call Simulation against this agent, the plugin needs one extra value: the SIP Call-ID of the inbound call. Tuner uses it to match the simulation call it just dialled with the session your agent syncs back, without it, simulated calls show up as ordinary production traffic.
This applies to inbound simulation, where Tuner dials your agent. For outbound simulation, pass the dialed SIP URI as recipient instead, see Outbound Simulation.
LiveKit attaches the SIP Call-ID to the SIP participant as the sip.callIDFull attribute. Read it after the room is connected and pass it to TunerPlugin as sipCorrelationId.
The SIP participant only becomes visible after the agent joins the room. Call ctx.connect() first, then read the correlation ID, then pass it to TunerPlugin:
export default async function entry(ctx: JobContext) { const session = new voice.AgentSession({ /* ... */ }); await ctx.connect(); // 1. connect first const sipCorrelationId = extractSipCorrelationId(ctx); // 2. then read new TunerPlugin(session, ctx, { sipCorrelationId, // 3. pass it in // ...other options }); await session.start({ agent, room: ctx.room });}
If ctx.connect() runs after you try to read participants, ctx.room.remoteParticipants will be empty and sipCorrelationId will be undefined. Simulated calls will still complete, but they won’t be linked to their Tuner simulation rows.
Tuner captures why each call ended. User disconnects and errors are tracked automatically.
@livekit/agents (Node) currently invokes shutdown callbacks with no arguments, so the JS SDK submits a hardcoded 'shutdown' reason instead of the actual disconnect reason. Once LiveKit’s Node SDK forwards the reason, the JS plugin will pass it through automatically.
Tuner requires a recording URL for every call. Provide a resolver function that returns the URL. If you don’t provide one, the plugin submits "pending" as a placeholder.
Provide a callable that receives a UsageSummary and returns the call cost. Implement the method that matches your pricing plan; the snippet below is illustrative.