> ## Documentation Index
> Fetch the complete documentation index at: https://docs.usetuner.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Connecting to LiveKit

> 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.

<Note>
  This guide covers both the **Python SDK** ([`tuner-livekit-sdk`](https://pypi.org/project/tuner-livekit-sdk/)) and the **Node.js SDK** ([`@usetuner.ai/livekit-sdk`](https://www.npmjs.com/package/@usetuner.ai/livekit-sdk)). Use the language switcher below to view the guide for your stack.
</Note>

## Video Tutorial

<Frame>
  <iframe width="100%" height="450" src="https://www.youtube.com/embed/OtnCEM_IlEY" title="Connecting to LiveKit - Tuner Integration Tutorial" frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowFullScreen />
</Frame>

## Prerequisites

* Tuner Active Account
* Configured Agent with provider "Custom API" in Tuner
* A LiveKit Agents project, either:
  * **Python** running [LiveKit Agents](https://docs.livekit.io/agents/) v1.4 or later, or
  * **Node.js 18+** running [`@livekit/agents`](https://github.com/livekit/agents-js) v1.4 or later with `@livekit/rtc-node` v0.13 or later.

## Overview

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.

<Steps>
  <Step title="Install the SDK">
    Add the package to your project.
  </Step>

  <Step title="Set Your Credentials">
    Configure your Tuner API key, workspace ID, and agent remote ID.
  </Step>

  <Step title="Add the Plugin">
    Drop two lines into your entrypoint and you're done.
  </Step>
</Steps>

**Estimated time:** 2 minutes from start to finish

<Tabs>
  <Tab title="Python">
    ## Step 1: Install the SDK

    ```shell theme={null}
    pip install tuner-livekit-sdk
    ```

    **Requirements:** Python ≥ 3.10, `livekit-agents >= 1.4`, `aiohttp >= 3.9`

    ## Step 2: Set Your Credentials

    You can configure credentials via environment variables or pass them directly in code.

    <Tabs>
      <Tab title="Environment Variables">
        ```shell theme={null}
        export TUNER_API_KEY="tr_api_..."
        export TUNER_WORKSPACE_ID="123"
        export TUNER_AGENT_ID="fa4da74c-..."
        ```

        | Variable             | Required | Description                                                                                           |
        | -------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
        | `TUNER_API_KEY`      | ✅        | Bearer token (starts with `tr_api_`)                                                                  |
        | `TUNER_WORKSPACE_ID` | ✅        | Your Tuner workspace ID                                                                               |
        | `TUNER_AGENT_ID`     | ✅        | **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`)                                                     |
      </Tab>

      <Tab title="Inline in Code">
        ```python theme={null}
        TunerPlugin(
            session, ctx,
            api_key="tr_api_...",
            workspace_id=123,
            agent_id="fa4da74c-...",
        )
        ```
      </Tab>
    </Tabs>

    <Tip>
      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.
    </Tip>

    ## Step 3: Add the Plugin

    Import `TunerPlugin` and add it right after creating your `AgentSession` — before calling `session.start()`:

    ```python theme={null}
    from tuner import TunerPlugin

    async def entrypoint(ctx: JobContext):
        session = AgentSession(...)
        TunerPlugin(session, ctx)          # wires itself automatically
        await session.start(...)
    ```

    That's it. The plugin listens to session events and submits call data to Tuner when the session ends.

    ## SIP correlation for simulation

    To run [Call Simulation](/docs/simulation/overview) against this agent, pass the inbound call's SIP `Call-ID` as `sip_correlation_id`. For why Tuner needs it and how the value can change name in transit, see [Simulation SIP setup](/docs/simulation/inbound/setup).

    <Info>
      This applies to **inbound** simulation, where Tuner dials your agent. For **outbound** simulation, pass the dialed SIP URI as `recipient` instead — see [Outbound Simulation](/docs/simulation/outbound).
    </Info>

    LiveKit exposes the value on the SIP participant as the `sip.callIDFull` attribute.

    <Info>
      Requires **`tuner-livekit-sdk >= 0.1.5`**.
    </Info>

    ### Helper: extract the SIP correlation ID

    Add this helper to your entrypoint module. It scans the room's remote participants for the SIP participant and returns its `sip.callIDFull` attribute.

    ```python theme={null}
    from livekit import rtc
    from livekit.agents import JobContext


    def _extract_sip_correlation_id(ctx: JobContext) -> str | None:
        for participant in ctx.room.remote_participants.values():
            if participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
                continue
            attributes = dict(getattr(participant, "attributes", {}) or {})
            sip_call_id_full = attributes.get("sip.callIDFull")
            if isinstance(sip_call_id_full, str) and sip_call_id_full:
                return sip_call_id_full
        return None
    ```

    For web calls (no SIP participant), this returns `None` and `TunerPlugin` falls back to its normal behaviour.

    ### Wire it into your entrypoint

    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`:

    ```python theme={null}
    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(...)
    ```

    <Warning>
      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.
    </Warning>

    ## Disconnection Reasons

    Tuner captures why each call ended. User disconnects and errors are tracked automatically. To record agent-initiated hangups, pass `reason="agent_hangup"` when ending the call programmatically:

    ```python theme={null}
    get_job_context().shutdown(reason="agent_hangup")
    ```

    Without this, agent hangups are indistinguishable from dropped calls in your analytics.

    <Accordion title="Full tool call example">
      ```python theme={null}
      @function_tool
      async 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.
    </Accordion>

    ## LangChain / LangGraph observability

    Using LangGraph or LangChain for your logic layer? The plugin can capture node transitions, tool calls, and timing alongside session data. See [LangChain / LangGraph](/docs/integrations/custom-stack/langchain#livekit).

    ## Configuration Options

    <AccordionGroup>
      <Accordion title="Call Type">
        The plugin auto-detects the call type (`phone_call` for SIP participants, `web_call` otherwise). Override it explicitly when needed:

        ```python theme={null}
        TunerPlugin(session, ctx, call_type="phone_call")
        TunerPlugin(session, ctx, call_type="web_call")
        ```
      </Accordion>

      <Accordion title="Recipient">
        The phone number or SIP URI of the callee. Optional — pass it when you want to record who the call was directed to:

        ```python theme={null}
        TunerPlugin(session, ctx, recipient="+15551234567")
        TunerPlugin(session, ctx, recipient="sip:+15551234567@provider.com")
        ```
      </Accordion>

      <Accordion title="Recording URL">
        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.

        ```python theme={null}
        # Static / pre-known URL
        async def my_resolver(room_name: str, job_id: str) -> str:
            return f"https://cdn.example.com/recordings/{job_id}.ogg"

        TunerPlugin(session, ctx, recording_url_resolver=my_resolver)
        ```

        ```python theme={null}
        # LiveKit Egress → S3
        async def egress_resolver(room_name: str, job_id: str) -> str:
            url = await my_egress_db.get_recording_url(room_name)
            return url or "pending"

        TunerPlugin(session, ctx, recording_url_resolver=egress_resolver)
        ```
      </Accordion>

      <Accordion title="Cost Calculation">
        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.

        ```python theme={null}
        def calculate_cost(usage) -> float:
            # rates below are USD per unit — convert to cents before returning
            llm_cost  = usage.llm_prompt_tokens     * 0.000_003
            llm_cost += usage.llm_completion_tokens * 0.000_015
            tts_cost  = usage.tts_characters_count  * 0.000_030
            stt_cost  = usage.stt_audio_duration    * 0.000_006
            return (llm_cost + tts_cost + stt_cost) * 100  # USD -> cents

        TunerPlugin(session, ctx, cost_calculator=calculate_cost)
        ```
      </Accordion>

      <Accordion title="Extra Metadata">
        Attach arbitrary key-value data to every call record:

        ```python theme={null}
        TunerPlugin(
            session, ctx,
            extra_metadata={
                "env": "production",
                "region": "us-east-1",
                "deployment": "v2.3.1",
            },
        )
        ```
      </Accordion>

      <Accordion title="Retry and Timeout">
        ```python theme={null}
        TunerPlugin(
            session, ctx,
            timeout_seconds=15.0,   # per-request timeout (default: 30.0)
            max_retries=5,          # retries on 5xx / 429 / network errors (default: 3)
        )
        ```
      </Accordion>

      <Accordion title="Agent Version Tracking">
        Track which version of your agent handled each call — useful when you update a prompt, swap a model, or change your pipeline:

        ```shell theme={null}
        AGENT_VERSION=42 python agent.py start
        ```

        Tuner reads it automatically. Bump the number on every deployment.

        Override in code (takes priority over the env var):

        ```python theme={null}
        TunerPlugin(session, ctx, agent_version=42)
        ```
      </Accordion>

      <Accordion title="Disable the Plugin">
        Useful for local development or test environments:

        ```python theme={null}
        import os

        TunerPlugin(
            session, ctx,
            enabled=os.getenv("ENV") == "production",
        )
        ```
      </Accordion>
    </AccordionGroup>

    ## Full Example

    ```python theme={null}
    import os
    from livekit import rtc
    from livekit.agents import JobContext, AgentSession
    from tuner import TunerPlugin


    def _extract_sip_correlation_id(ctx: JobContext) -> str | None:
        for participant in ctx.room.remote_participants.values():
            if participant.kind != rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
                continue
            attributes = dict(getattr(participant, "attributes", {}) or {})
            sip_call_id_full = attributes.get("sip.callIDFull")
            if isinstance(sip_call_id_full, str) and sip_call_id_full:
                return sip_call_id_full
        return None


    def calculate_cost(usage) -> float:
        usd = (
            usage.llm_prompt_tokens     * 0.000_003
            + usage.llm_completion_tokens * 0.000_015
            + usage.tts_characters_count  * 0.000_030
        )
        return usd * 100  # Tuner expects USD cents


    async def get_recording_url(room_name: str, job_id: str) -> str:
        return await my_storage.get_url(job_id) or "pending"


    async def entrypoint(ctx: JobContext):
        await ctx.connect()
        sip_correlation_id = _extract_sip_correlation_id(ctx)

        session = AgentSession(...)

        TunerPlugin(
            session,
            ctx,
            api_key=os.environ["TUNER_API_KEY"],
            workspace_id=int(os.environ["TUNER_WORKSPACE_ID"]),
            agent_id=os.environ["TUNER_AGENT_ID"],
            call_type="phone_call",
            recording_url_resolver=get_recording_url,
            cost_calculator=calculate_cost,
            sip_correlation_id=sip_correlation_id,
            extra_metadata={"env": "prod", "region": "us-east-1"},
            timeout_seconds=20.0,
            max_retries=3,
            enabled=True,
        )

        await session.start(...)
    ```

    ## Troubleshooting

    <AccordionGroup>
      <Accordion title="Calls don't appear in Tuner">
        * Verify that `TUNER_AGENT_ID` is the **Agent Remote ID** from **Agent Settings → Agent Connection** (not your agent's display name).
        * Confirm `TUNER_WORKSPACE_ID` is correct.
        * Check your application logs for any error messages from the plugin.
      </Accordion>

      <Accordion title="Unauthorized (401)">
        * Ensure `TUNER_API_KEY` starts with `tr_api_` and is valid.
        * Confirm the API key belongs to the correct workspace.
      </Accordion>

      <Accordion title="Recording URL shows as 'pending'">
        * You haven't provided a `recording_url_resolver`. Add one that returns the actual recording URL for each call.
        * If using LiveKit Egress, ensure the recording has finished processing before the resolver is called.
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="Node.js">
    ## Step 1: Install the SDK

    ```shell theme={null}
    npm install @usetuner.ai/livekit-sdk
    ```

    **Requirements:** Node 18+, `@livekit/agents >= 1.4.0`, `@livekit/rtc-node >= 0.13.0`

    ## Step 2: Set Your Credentials

    You can configure credentials via environment variables or pass them directly in code.

    <Tabs>
      <Tab title="Environment Variables">
        ```shell theme={null}
        export TUNER_API_KEY="tr_api_..."
        export TUNER_WORKSPACE_ID="123"
        export TUNER_AGENT_ID="fa4da74c-..."
        ```

        | Variable             | Required | Description                                                                                           |
        | -------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
        | `TUNER_API_KEY`      | ✅        | Bearer token (starts with `tr_api_`)                                                                  |
        | `TUNER_WORKSPACE_ID` | ✅        | Your Tuner workspace ID                                                                               |
        | `TUNER_AGENT_ID`     | ✅        | **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`)                                                     |
      </Tab>

      <Tab title="Inline in Code">
        ```ts theme={null}
        new TunerPlugin(session, ctx, {
          apiKey: 'tr_api_...',
          workspaceId: 123,
          agentId: 'fa4da74c-...',
        });
        ```
      </Tab>
    </Tabs>

    <Tip>
      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.
    </Tip>

    ## Step 3: Add the Plugin

    Import `TunerPlugin` and add it right after creating your `AgentSession` — before calling `session.start()`:

    ```ts theme={null}
    import { TunerPlugin } from '@usetuner.ai/livekit-sdk';
    import { voice, type JobContext } from '@livekit/agents';

    export default async function entry(ctx: JobContext) {
      const session = new voice.AgentSession({ /* ... */ });
      new TunerPlugin(session, ctx);             // wires itself automatically
      await session.start({ agent, room: ctx.room });
    }
    ```

    That's it. The plugin listens to session events and submits call data to Tuner when the session ends.

    ## SIP correlation for simulation

    To run [Call Simulation](/docs/simulation/overview) against this agent, pass the inbound call's SIP `Call-ID` as `sipCorrelationId`. For why Tuner needs it and how the value can change name in transit, see [Simulation SIP setup](/docs/simulation/inbound/setup).

    <Info>
      This applies to **inbound** simulation, where Tuner dials your agent. For **outbound** simulation, pass the dialed SIP URI as `recipient` instead — see [Outbound Simulation](/docs/simulation/outbound).
    </Info>

    LiveKit exposes the value on the SIP participant as the `sip.callIDFull` attribute.

    ### Helper: extract the SIP correlation ID

    Add this helper to your entrypoint module. It scans the room's remote participants for the SIP participant and returns its `sip.callIDFull` attribute.

    ```ts theme={null}
    import { ParticipantKind } from '@livekit/rtc-node';
    import type { JobContext } from '@livekit/agents';

    function extractSipCorrelationId(ctx: JobContext): string | undefined {
      for (const participant of ctx.room.remoteParticipants.values()) {
        if (participant.kind !== ParticipantKind.SIP) continue;
        const sipCallIdFull = participant.attributes?.['sip.callIDFull'];
        if (typeof sipCallIdFull === 'string' && sipCallIdFull) return sipCallIdFull;
      }
      return undefined;
    }
    ```

    For web calls (no SIP participant), this returns `undefined` and `TunerPlugin` falls back to its normal behaviour.

    ### Wire it into your entrypoint

    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`:

    ```ts theme={null}
    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 });
    }
    ```

    <Warning>
      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.
    </Warning>

    ## Disconnection Reasons

    Tuner captures why each call ended. User disconnects and errors are tracked automatically.

    <Info>
      `@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.
    </Info>

    ## Configuration Options

    <AccordionGroup>
      <Accordion title="Call Type">
        The plugin auto-detects the call type (`phone_call` for SIP participants, `web_call` otherwise). Override it explicitly when needed:

        ```ts theme={null}
        new TunerPlugin(session, ctx, { callType: 'phone_call' });
        new TunerPlugin(session, ctx, { callType: 'web_call' });
        ```
      </Accordion>

      <Accordion title="Recording URL">
        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.

        ```ts theme={null}
        async function getRecordingUrl(roomName: string, jobId: string): Promise<string> {
          return (await myStorage.getUrl(jobId)) ?? 'pending';
        }

        new TunerPlugin(session, ctx, { recordingUrlResolver: getRecordingUrl });
        ```
      </Accordion>

      <Accordion title="Cost Calculation">
        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.

        ```ts theme={null}
        import type { metrics } from '@livekit/agents';

        function calculateCost(usage: metrics.UsageSummary): number {
          const usd =
            usage.llmPromptTokens * 0.000_003 +
            usage.llmCompletionTokens * 0.000_015 +
            usage.ttsCharactersCount * 0.000_030;
          return usd * 100; // Tuner expects USD cents
        }

        new TunerPlugin(session, ctx, { costCalculator: calculateCost });
        ```
      </Accordion>

      <Accordion title="Extra Metadata">
        Attach arbitrary key-value data to every call record:

        ```ts theme={null}
        new TunerPlugin(session, ctx, {
          extraMetadata: {
            env: 'production',
            region: 'us-east-1',
            deployment: 'v2.3.1',
          },
        });
        ```
      </Accordion>

      <Accordion title="Retry and Timeout">
        ```ts theme={null}
        new TunerPlugin(session, ctx, {
          timeoutSeconds: 15,   // per-request timeout (default: 30)
          maxRetries: 5,        // retries on 5xx / 429 / network errors (default: 3)
        });
        ```
      </Accordion>

      <Accordion title="Agent Version Tracking">
        Track which version of your agent handled each call — useful when you update a prompt, swap a model, or change your pipeline:

        ```shell theme={null}
        AGENT_VERSION=42 node agent.js start
        ```

        Tuner reads it automatically. Bump the number on every deployment.

        Override in code (takes priority over the env var):

        ```ts theme={null}
        new TunerPlugin(session, ctx, { agentVersion: 42 });
        ```
      </Accordion>

      <Accordion title="Disable the Plugin">
        Useful for local development or test environments:

        ```ts theme={null}
        new TunerPlugin(session, ctx, {
          enabled: process.env.NODE_ENV === 'production',
        });
        ```
      </Accordion>
    </AccordionGroup>

    ## Full Example

    ```ts theme={null}
    import { TunerPlugin } from '@usetuner.ai/livekit-sdk';
    import { voice, type JobContext, type metrics } from '@livekit/agents';

    function calculateCost(usage: metrics.UsageSummary): number {
      const usd =
        usage.llmPromptTokens * 0.000_003 +
        usage.llmCompletionTokens * 0.000_015 +
        usage.ttsCharactersCount * 0.000_030;
      return usd * 100; // Tuner expects USD cents
    }

    async function getRecordingUrl(roomName: string, jobId: string): Promise<string> {
      return (await myStorage.getUrl(jobId)) ?? 'pending';
    }

    export default async function entry(ctx: JobContext) {
      await ctx.connect();
      const sipCorrelationId = extractSipCorrelationId(ctx);

      const session = new voice.AgentSession({ /* ... */ });

      new TunerPlugin(session, ctx, {
        apiKey: process.env.TUNER_API_KEY,
        workspaceId: Number.parseInt(process.env.TUNER_WORKSPACE_ID!, 10),
        agentId: process.env.TUNER_AGENT_ID,
        callType: 'phone_call',
        recordingUrlResolver: getRecordingUrl,
        costCalculator: calculateCost,
        sipCorrelationId,
        extraMetadata: { env: 'prod', region: 'us-east-1' },
        agentVersion: 42,
        timeoutSeconds: 20,
        maxRetries: 3,
        enabled: process.env.NODE_ENV === 'production',
      });

      await session.start({ agent, room: ctx.room });
    }
    ```

    ## Troubleshooting

    <AccordionGroup>
      <Accordion title="Calls don't appear in Tuner">
        * Verify that `TUNER_AGENT_ID` is the **Agent Remote ID** from **Agent Settings → Agent Connection** (not your agent's display name).
        * Confirm `TUNER_WORKSPACE_ID` is correct.
        * Check your application logs for any error messages from the plugin.
      </Accordion>

      <Accordion title="Unauthorized (401)">
        * Ensure `TUNER_API_KEY` starts with `tr_api_` and is valid.
        * Confirm the API key belongs to the correct workspace.
      </Accordion>

      <Accordion title="Recording URL shows as 'pending'">
        * You haven't provided a `recordingUrlResolver`. Add one that returns the actual recording URL for each call.
        * If using LiveKit Egress, ensure the recording has finished processing before the resolver is called.
      </Accordion>
    </AccordionGroup>
  </Tab>
</Tabs>

## Webhooks

Tuner can notify your systems when it has something to report on a call — see [Webhooks](/docs/integrations/webhooks).

## What's Next?

<CardGroup cols={2}>
  <Card title="Configuring Your Agent" icon="gear" href="/docs/getting-started/quickstart">
    Set up call outcomes, user intents, and evals.
  </Card>

  <Card title="Custom Integration" icon="code" href="/docs/integrations/custom-stack/overview">
    Learn about the underlying API if you need more control.
  </Card>

  <Card title="Classifying Calls" icon="tags" href="/docs/evaluation/call-classification">
    Define how Tuner categorizes your calls.
  </Card>

  <Card title="Real-Time Alerts" icon="bell" href="/docs/observability/alerts">
    Get notified when issues are detected.
  </Card>
</CardGroup>
