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

# LiveKit Agents

> Give your LiveKit voice agents a real-world account. LiveKit runs the realtime pipeline — STT, LLM, TTS, turn detection, and the room — while Naive supplies per-user identity, a funded virtual card, 1,000+ connectable apps, and policy-bounded, human-approved spend — delivered as a scoped MCP toolset the agent can call mid-call.

<Frame caption="LiveKit runs the voice pipeline · Naive lets it transact">
  <img src="https://github.com/livekit.png" alt="LiveKit" width="96" />
</Frame>

<Note>
  LiveKit and LiveKit Agents are trademarks of their respective owners, used here for
  identification and integration guidance only. No endorsement, partnership, or affiliation is
  implied.
</Note>

[LiveKit Agents](https://docs.livekit.io/agents/) is the framework for building **realtime voice
AI**: an [`AgentSession`](https://docs.livekit.io/agents/build/) wires up speech-to-text, an LLM,
text-to-speech, and turn detection into one low-latency loop, and a
[worker](https://docs.livekit.io/agents/worker/) dispatches a fresh agent per caller. Its
[MCP support](https://docs.livekit.io/agents/build/tools/mcp/) lets that agent call any MCP server's
tools — spoken request in, real action out.

* **What LiveKit ships** — a realtime [`AgentSession`](https://docs.livekit.io/agents/build/)
  pipeline (STT → LLM → TTS + turn detection), a [worker/dispatch](https://docs.livekit.io/agents/worker/)
  model that spins up one agent per participant, first-class
  [tools](https://docs.livekit.io/agents/build/tools/) and
  [`mcp.MCPServerHTTP`](https://docs.livekit.io/reference/python/livekit/agents/llm/mcp.html) for
  remote MCP servers, and the rooms/telephony transport underneath.
* **What it doesn't ship** — a way for that voice agent to *act as a real account*: sign up for a
  SaaS tool, hold a funded card, or spend within a budget you control, per end-user.

That's the half Naive adds. You keep LiveKit's realtime loop; Naive gives each caller's agent a
[tenant identity](/docs/getting-started/users), a [virtual card](/docs/getting-started/cards),
[1,000+ third-party connections](/docs/getting-started/connections), and an
[Account Kit](/docs/architecture/account-kits) that bounds exactly what the agent can do — enforced
**server-side**, with [human approval](/docs/getting-started/approvals) on the sensitive actions.

## How the pairing works

* Naive ships a **hosted MCP server** and mints **per-user [sessions](/docs/architecture/sessions)** —
  short-lived, revocable SSE endpoints whose tool list is the fused native + third-party toolset,
  already filtered by that user's Account Kit. See the
  [delegated MCP sessions explainer](https://usenaive.ai/blog/delegated-revocable-mcp-sessions-for-ai-agents)
  for the full lifecycle model.
* In the LiveKit **entrypoint** (one job per caller), you mint a session for that participant and
  hand its URL + headers to `mcp.MCPServerHTTP(..., transport_type="sse", headers=...)` — the scoped
  bearer rides in the request headers, never in the URL. Pass it to the agent via `mcp_servers=[...]`.
* The `AgentSession` runs the loop: the caller speaks → STT → the LLM picks a Naive tool → the
  result is spoken back via TTS. Every call runs as that one user, gated on Naive's servers.
  Sensitive actions resolve to a `pending_approval` payload the agent can read aloud.

```
  LiveKit Agents (realtime pipeline)        Naive
  ─────────────────────────────────        ─────
  caller speaks ─▶ STT ─▶ LLM (reasons)
                          │  tool call (naive_cards_create)
                          ▼  mcp.MCPServerHTTP (SSE + headers)
  AgentSession ── /mcp/sse/sess_… ────────────▶ per-user session
    (Authorization: Bearer nv_sess_…)           │  AccountKit-gated, scoped to one user
                                                ▼
                                         connect GitHub · issue a $50 card · run a capability
                                                │
                                         sensitive? → 202 pending_approval (human-in-the-loop)
                          ┌─────────────────────┘
                          ▼
  LLM ─▶ TTS ─▶ "I've queued that card for approval." ─▶ caller hears it
```

<Note>
  **Tested against:** `livekit-agents` **1.6.5** (`AgentServer`, `AgentSession`, `Agent`, and
  `livekit.agents.llm.mcp.MCPServerHTTP`), the MCP Python SDK **1.28.1** (the transport under
  `MCPServerHTTP`), and Naive **API v2** (hosted MCP server over SSE + per-user sessions), on
  **Python ≥ 3.9**. Live-verified: passing Naive's session headers to `MCPServerHTTP(transport_type="sse",
    headers=...)` discovers Naive's tools (and `allowed_tools=[...]` narrows them); a wrong bearer fails
  MCP session initialization.

  Version assumptions: `MCPServerHTTP` takes `headers` at construction and does **not** support
  rotating them after connect ([livekit/agents#5542](https://github.com/livekit/agents/issues/5542)),
  so mint the Naive session **inside the entrypoint** (per job/caller) and give it a `ttl_ms` that
  covers the whole call — up to 24h. Naive's session URL contains `/mcp/sse/…`, so set
  `transport_type="sse"` (LiveKit also supports `"streamable_http"` for `…/mcp` endpoints). There is no
  Python Naive SDK yet, so the control plane (Account Kit, user, session) is shown over the REST API;
  you can equally provision from the [dashboard](https://usenaive.ai/developers), the CLI, or the Node
  SDK. Pin your versions and set the STT/LLM/TTS models to providers you have access to.
</Note>

## Prerequisites

* A Naive API key (`nv_sk_...`) — get one from the [dashboard](https://usenaive.ai/developers).
* A LiveKit project (`LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`) plus model access for
  the STT/LLM/TTS you pick (this guide uses [LiveKit Inference](https://docs.livekit.io/agents/models/);
  provider plugins like `openai`, `deepgram`, `cartesia`, `silero` work identically).
* Python ≥ 3.9.

```bash theme={"theme":"css-variables"}
pip install "livekit-agents[silero,turn-detector]" mcp httpx
```

```bash theme={"theme":"css-variables"}
export NAIVE_API_KEY=nv_sk_live_...
export LIVEKIT_URL=wss://your-project.livekit.cloud
export LIVEKIT_API_KEY=...
export LIVEKIT_API_SECRET=...
```

## Minimal viable integration

The shortest path to a LiveKit voice agent that can actually transact: define a policy and provision
a user (Naive control plane, once), then — inside the per-caller entrypoint — mint a session, point
`mcp.MCPServerHTTP` at its scoped SSE endpoint, and hand it to the agent.

<Steps>
  <Step title="Define the policy, then provision a user">
    An [Account Kit](/docs/architecture/account-kits) is the spend/capability policy. Here a tenant user
    gets a card (capped at \$500, approval required), the vault, and an allowlist of apps. Everything the
    agent does is bounded by this kit — server-side. These are one-time control-plane calls:

    ```bash theme={"theme":"css-variables"}
    # Control plane: a reusable policy template.
    curl -X POST https://api.usenaive.ai/v1/account-kits \
      -H "Authorization: Bearer $NAIVE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Pro",
        "primitives_config": {
          "cards": { "enabled": true, "requiresApproval": true, "defaults": { "spending_limit_cents": 50000 } },
          "vault": { "enabled": true }
        },
        "connections_config": { "mode": "allowlist", "toolkits": ["github", "gmail", "stripe"] }
      }'
    # → { "id": "<kit_id>", ... }

    # Provision one of your end-users and assign the kit.
    curl -X POST https://api.usenaive.ai/v1/users \
      -H "Authorization: Bearer $NAIVE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "external_id": "user_123", "email": "alice@acme.com", "account_kit_id": "<kit_id>" }'
    # → { "id": "<user_id>", ... }
    ```
  </Step>

  <Step title="Mint a per-user MCP session">
    At runtime, mint a [session](/docs/architecture/sessions) for the user. It returns the scoped SSE
    endpoint and a bearer that lives in the headers — never in the URL. Because `MCPServerHTTP` headers
    are fixed at construction, ask for a `ttl_ms` that comfortably covers the call:

    ```python theme={"theme":"css-variables"}
    import os
    import httpx

    NAIVE_API = "https://api.usenaive.ai"
    AUTH = {"Authorization": f"Bearer {os.environ['NAIVE_API_KEY']}"}

    def mint_session(user_id: str, ttl_ms: int = 60 * 60 * 1000) -> dict:
        r = httpx.post(
            f"{NAIVE_API}/v1/users/{user_id}/sessions",
            headers=AUTH,
            json={"ttl_ms": ttl_ms},  # default 15 min, max 24h
        )
        r.raise_for_status()
        return r.json()  # { id, expires_at, mcp: { url, headers, expires_at } }
    ```
  </Step>

  <Step title="Wire Naive into the voice agent">
    In the LiveKit **entrypoint** (one job per caller), resolve which user this call belongs to, mint
    their session, and build `mcp.MCPServerHTTP` from `mcp.url` + `mcp.headers`. Pass it to the `Agent`
    via `mcp_servers=[...]` — LiveKit discovers Naive's tools and the LLM can call them mid-conversation:

    ```python theme={"theme":"css-variables"}
    from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli, inference
    from livekit.agents.llm import mcp
    from livekit.plugins import silero

    server = AgentServer()

    @server.rtc_session()
    async def entrypoint(ctx: JobContext):
        await ctx.connect()

        # Map this call to one of your end-users (e.g. from job metadata / a phone lookup).
        user_id = "<user_id>"  # from the control-plane step
        session_info = mint_session(user_id)
        m = session_info["mcp"]

        # Naive is SSE; the scoped bearer rides in the Authorization header, never the URL.
        naive = mcp.MCPServerHTTP(
            url=m["url"],
            transport_type="sse",
            headers=m["headers"],
            client_session_timeout_seconds=15,
        )

        agent = Agent(
            instructions=(
                "You are Alice's operations assistant. Use the Naive tools to act on her real "
                "account. Sensitive actions need approval — if a result says pending_approval, tell "
                "her you've queued it for approval instead of claiming it's done."
            ),
            mcp_servers=[naive],
        )

        session = AgentSession(
            vad=silero.VAD.load(),
            stt=inference.STT("deepgram/nova-3", language="multi"),
            llm=inference.LLM("openai/gpt-4.1-mini"),
            tts=inference.TTS("cartesia/sonic-3"),
        )

        await session.start(agent=agent, room=ctx.room)
        await session.generate_reply(
            instructions="Greet Alice and ask what she'd like to do with her account."
        )

    if __name__ == "__main__":
        cli.run_app(server)
    ```

    On a call, Alice can say *"connect my GitHub, then issue a \$50 card called 'Ads budget'"* — the
    agent discovers GitHub (`naive_connections_connect`), returns a connect link for her to authorize,
    and attempts to issue the card (`naive_cards_create`) — a **real** card on Alice's account, capped
    by her kit. The realtime pipeline is LiveKit's; the real-world actions are Naive's.
  </Step>
</Steps>

That's the moat: the same voice loop that would otherwise just *talk about* spending money now
issues a policy-bounded card on a specific caller's account.

<Note>
  `mcp_servers` also exists on `AgentSession(...)` if you'd rather scope the toolset to the whole
  session than to a single `Agent`. Either way LiveKit initializes the MCP connection when the session
  starts and closes it when the session ends.
</Note>

## Extension: human-in-the-loop spend

Because the kit set `cards.requiresApproval: true`, the agent **cannot** silently spend. You get two
complementary layers — pair them for defense in depth:

* **In the pipeline (scope + phrasing)** — narrow the toolset with `allowed_tools=[...]` so the
  agent can only touch the primitives you intend, and use a `tool_result_resolver` to turn a
  `pending_approval` payload into a clean sentence the TTS reads back. Treat this as UX.
* **On the server (the real boundary)** — Naive freezes the action and returns a pending approval
  (HTTP `202`) instead of a live card. This holds no matter what runtime calls it, and can't be
  bypassed from the prompt or the agent config.

```python theme={"theme":"css-variables"}
from livekit.agents.llm import mcp
from livekit.agents.llm.mcp import MCPToolResultContext

def spoken_result(ctx: MCPToolResultContext):
    """Shape what the voice agent says when Naive queues an approval."""
    for part in ctx.result.content:
        text = getattr(part, "text", "")
        if "pending_approval" in text:
            return "That action needs approval — I've queued it for a human to confirm."
    return ctx.result  # let non-gated results flow through unchanged

naive = mcp.MCPServerHTTP(
    url=m["url"],
    transport_type="sse",
    headers=m["headers"],
    allowed_tools=["naive_connections_connect", "naive_cards_create"],  # least privilege
    tool_result_resolver=spoken_result,
)
```

<Info>
  LiveKit has no native pre-tool *rejection* hook — `allowed_tools` decides what the agent may call,
  and `tool_result_resolver` only shapes the result after Naive has already gated it. The real
  enforcement is Naive's server-side approval gate below, which can't be bypassed from the agent
  config, the tool list, or the resolver.
</Info>

Independently, when a call reaches Naive, the tool result comes back as a pending approval rather
than a live card — regardless of what the client did:

```json theme={"theme":"css-variables"}
{
  "status": "pending_approval",
  "approval_id": "65589c8b-e033-4a65-b16c-379211c94429",
  "action_type": "cards.create",
  "primitive": "cards",
  "title": "Issue virtual card \"Ads budget\""
}
```

Your app resolves it out of band — the human approves in your dashboard or UI, and on approval Naive
**replays the frozen action** server-side:

```python theme={"theme":"css-variables"}
def list_pending(user_id: str) -> list[dict]:
    r = httpx.get(
        f"{NAIVE_API}/v1/users/{user_id}/approvals",
        headers=AUTH,
        params={"status": "pending"},
    )
    r.raise_for_status()
    return r.json()["approvals"]

def approve(user_id: str, approval_id: str) -> dict:
    r = httpx.post(
        f"{NAIVE_API}/v1/users/{user_id}/approvals/{approval_id}/approve",
        headers=AUTH,
    )
    r.raise_for_status()
    return r.json()

# Find what the agent queued for Alice, then approve (or deny) it.
for a in list_pending("<user_id>"):
    # ...show a["title"] / a["action_type"] to a human in your UI...
    approve("<user_id>", a["id"])  # API replays cards.create → real card
```

See [Approvals](/docs/getting-started/approvals) for the full lifecycle (`pending → executed /
failed / denied`) and the deny endpoint.

<Info>
  Approvals are only enforced for agent (API-key / MCP) calls on real tenant users. A human acting
  in your dashboard, and agent calls on the account's own default agent profile, bypass the gate — so
  end-user agents stay governed while your own automation isn't slowed down.
</Info>

## Alternative: one agent per tenant

A LiveKit worker already dispatches **one job per caller**, so multi-tenant is the natural shape:
resolve the user from the job's metadata, mint a fresh session for them, and build the MCP server
bound to it — inside the entrypoint. `allowed_tools` narrows a caller's toolset to exactly the
primitives you want that agent to touch — a second layer on top of the Account Kit:

```python theme={"theme":"css-variables"}
import json

@server.rtc_session()
async def entrypoint(ctx: JobContext):
    await ctx.connect()

    # One dispatched job == one caller. Carry the tenant on the job metadata.
    meta = json.loads(ctx.job.metadata or "{}")
    user_id = meta["naive_user_id"]

    m = mint_session(user_id)["mcp"]  # fresh, per-user session
    naive = mcp.MCPServerHTTP(
        url=m["url"],
        transport_type="sse",
        headers=m["headers"],
        allowed_tools=["naive_connections_connect", "naive_cards_create"],
    )

    agent = Agent(instructions="...", mcp_servers=[naive])
    session = AgentSession(
        vad=silero.VAD.load(),
        stt=inference.STT("deepgram/nova-3", language="multi"),
        llm=inference.LLM("openai/gpt-4.1-mini"),
        tts=inference.TTS("cartesia/sonic-3"),
    )
    await session.start(agent=agent, room=ctx.room)

# Same agent definition, different caller — isolated identity, spend, and approvals each time.
```

Nothing about the agent widens what a user may do: the toolset is the intersection of the session,
that user's Account Kit, and any `allowed_tools` filter — enforced on Naive's servers. Because
`MCPServerHTTP` headers are fixed at connect, mint the session per job with a `ttl_ms` that covers
the call, and `DELETE /v1/users/{user_id}/sessions/{id}` to revoke it early when the call ends.

<Info>
  **Long calls?** A Naive session defaults to 15 minutes (max 24h) and its bearer can't be rotated
  mid-connection. Set `ttl_ms` to cover the expected call length when you mint it in the entrypoint;
  for very long-running sessions, end the call and start a fresh job (which mints a new session) rather
  than trying to update the header in place.
</Info>

## What stays enforced

No matter how the agent is wired, the policy is enforced where it matters — on Naive's servers, not
in your prompt or your agent config:

* **Identity** — every action runs as a specific [tenant user](/docs/getting-started/users), fully
  isolated from your other users.
* **Capability bounds** — the [Account Kit](/docs/architecture/account-kits) decides which primitives
  and which apps the agent can touch (`allowlist` / `blocklist` / per-tool).
* **Scoped spend** — virtual cards are capped per card and per user; the model can't raise its own
  limit.
* **Human-in-the-loop** — sensitive actions ([cards](/docs/getting-started/cards),
  [domains](/docs/getting-started/domains), [KYC](/docs/getting-started/verification),
  [formation](/docs/getting-started/formation), connecting an app) freeze as
  [approvals](/docs/getting-started/approvals) until a human says yes.
* **Scoped, expiring access** — the agent holds a per-user session bearer, not your API key; it
  expires and you can `DELETE /v1/users/:user_id/sessions/:id` to revoke it early.

## Next steps

* [The Governed Agent Profile, End to End](https://usenaive.ai/blog/the-governed-agent-profile) — the developer pillar on scoped identity, spend caps, approvals, and revocation
* [Delegated, Revocable MCP Sessions for AI Agents](https://usenaive.ai/blog/delegated-revocable-mcp-sessions-for-ai-agents) — the concept post behind per-user SSE sessions (this guide's transport)
* [How to Revoke an AI Agent's Access Instantly](https://usenaive.ai/blog/how-to-revoke-ai-agent-access-instantly) — kill a session mid-call when a caller hangs up or policy changes
* [MCP server](/docs/mcp/overview) — the hosted SSE server and its full [tool list](/docs/mcp/tools)
* [Sessions](/docs/architecture/sessions) — per-user MCP sessions, TTL, and revocation
* [Account Kits](/docs/architecture/account-kits) — author spend/capability policy
* [Approvals](/docs/getting-started/approvals) — the human-in-the-loop lifecycle
* [Pydantic AI](/docs/integrations/pydantic-ai) · [LlamaIndex](/docs/integrations/llamaindex) · [Agno](/docs/integrations/agno) · [smolagents](/docs/integrations/smolagents) · [DSPy](/docs/integrations/dspy) · [Letta](/docs/integrations/letta) — the same MCP-session pairing for other Python stacks
