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

# Move from LiveKit or Pipecat

> Replace a LiveKit or Pipecat voice-agent stack with a 60db agent: what maps to what, a field-by-field SIP trunk mapping, and a rollout you can undo

## Overview

You do not need LiveKit or Pipecat to run a 60db agent. A 60db [agent](https://docs.60db.ai/features/agents.md) already does the listening, the model, the voice, the tools and the call itself, and you reach it with the HTTP API. This page maps what you built on those platforms to what you set here, and how to move one number at a time without risk.

If you want to keep LiveKit in the path and only use 60db speech models inside it, that is a different topic: see the [LiveKit integration](https://docs.60db.ai/integrations/livekit.md).

## Rolling out features

> [!NOTE]
>
> **Rolling out.** These parts of the migration use names that are defined but are still being released: **MCP servers** (`mcp_servers`, `POST /agents/{id}/mcp/refresh`), **agent handoff** (`handoff_targets`), **warm transfer** (`transfer_targets[].warm`) and **agent tests** (`POST /agents/{id}/tests`). Until they reach your workspace, the matching rows below do not apply. The rest of the page does not depend on them.

## What replaces what

| On LiveKit or Pipecat you wrote or ran | In 60db |
| - | - |
| An agent worker or pipeline: STT, LLM, TTS, turn detection, interruptions | One saved **agent**: `script`, `greeting`, `voice_id`, `listen_languages`, `speak_languages`. The engine does the rest |
| Function tools and a handler | Agent `tools` with a signed `tool_webhook_url` ([Tools and webhooks](https://docs.60db.ai/features/agents-tools-and-webhooks.md)), or MCP servers (`mcp_servers`, then `POST /agents/{id}/mcp/refresh`, rolling out) |
| Handoff between agents | `handoff_targets` (rolling out) |
| Transfer to a person (cold or warm) | `transfer_targets`, with `warm` per target (rolling out) and `bridge_transfers` on the phone line ([Bring your own SIP](https://docs.60db.ai/features/agents-bring-your-own-sip.md#transfers)) |
| Inbound SIP trunk and dispatch rule | A **phone line**: `inbound_number`, `agent_id`, `allowed_addresses`, `allowed_numbers`, `numbers` for more numbers ([Bring your own SIP](https://docs.60db.ai/features/agents-bring-your-own-sip.md)) |
| Outbound SIP trunk and "create SIP participant" | The same phone line, and [Place call](https://docs.60db.ai/api-reference/agents/place-call.md) |
| A web or mobile client joining a room | [Start browser call](https://docs.60db.ai/api-reference/agents/start-web-call.md) with the agent and the browser's WebRTC offer |
| A telephony platform streaming audio to you (Twilio Media Streams, a Pipecat websocket transport) | [Start stream call](https://docs.60db.ai/api-reference/agents/start-stream-call.md), then connect to the returned `wss://` URL. The Twilio style is accepted as it is |
| Your own test harness | `POST /agents/{id}/tests` (rolling out) |
| Retrieval (RAG) over your documents | `knowledge_collection_id` (a Smart Memory collection), or `knowledge_webhook_url` to keep the documents on your own server ([Knowledge for agents](https://docs.60db.ai/features/agents-knowledge.md)). Searched in the background, so no reply waits on it |
| Metrics, recordings, transcripts | [Get call](https://docs.60db.ai/api-reference/agents/get-call.md) (latency, interruptions, network), [recordings](https://docs.60db.ai/api-reference/agents/get-recording.md), [transcripts](https://docs.60db.ai/api-reference/agents/get-transcript.md), and call events ([Tools and webhooks](https://docs.60db.ai/features/agents-tools-and-webhooks.md#events)) |

HelloKit and 60db names for the same things are in [Names: HelloKit and 60db](https://docs.60db.ai/features/agents-glossary.md).

## The same thing, step by step

**1. Save the agent**

[Create an agent](https://docs.60db.ai/api-reference/agents/create-agent.md) once. Copy your prompt into `script`, pick a `voice_id`, list the languages, and re-declare each function as a tool. Keep the `webhook_secret` it returns, shown only once

**2. Phone, in**

Create a phone line with the number your carrier dials and the carrier's source addresses. No room, worker or dispatch rule is involved

**3. Phone, out**

[Place a call](https://docs.60db.ai/api-reference/agents/place-call.md) with the agent, `to` and `phone_line_id`

**4. Browser**

[Start a browser call](https://docs.60db.ai/api-reference/agents/start-web-call.md) with the agent and the offer. The dashboard's **Test call** is the reference client

**5. Your own audio source**

[Start a stream call](https://docs.60db.ai/api-reference/agents/start-stream-call.md), connect, send 8 kHz frames, play what comes back

**6. Tools, transfer, knowledge**

Set `tool_webhook_url` and `tools`, `transfer_targets`, and `knowledge_collection_id` or `knowledge_webhook_url`

### Phone, in

```bash theme={null}
curl -X POST https://api.60db.ai/agents/phone-lines \
  -H "Authorization: Bearer $SIXTYDB_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "kind": "byo", "name": "main", "domain": "sip.carrier.example", "caller_id": "+15550100",
    "inbound_number": "+15550301", "agent_id": "AGENT_ID",
    "allowed_addresses": ["203.0.113.7"]
  }'
```

Point the carrier at `hellokit-sip.60db.ai` (UDP 5060, G.711). No username or password is needed if the carrier trusts your IP.

## Worked example: LiveKit SIP trunks to a 60db phone line

Field names below are LiveKit's SIP trunk fields at the time of writing. Check your LiveKit version before copying.

### Inbound trunk and dispatch rule

LiveKit inbound trunk:

```json theme={null}
{
  "name": "Main inbound",
  "numbers": ["+15550301", "+15550302"],
  "allowed_addresses": ["203.0.113.7", "198.51.100.0/24"],
  "allowed_numbers": ["+919876543210"],
  "krisp_enabled": true
}
```

LiveKit dispatch rule that sends calls to a room with an agent:

```json theme={null}
{
  "name": "Main dispatch",
  "trunk_ids": ["ST_xxxx"],
  "rule": { "dispatchRuleIndividual": { "roomPrefix": "call-" } },
  "roomConfig": { "agents": [{ "agentName": "front-desk" }] }
}
```

The 60db phone line that replaces both:

```json theme={null}
{
  "kind": "byo",
  "name": "Main inbound",
  "domain": "sip.carrier.example",
  "caller_id": "+15550301",
  "inbound_number": "+15550301",
  "numbers": [{ "number": "+15550302", "agent_id": null }],
  "agent_id": "9c2e1d7a-0b3f-4e6a-8f1d-5a7b9c0e2d44",
  "allowed_addresses": ["203.0.113.7", "198.51.100.0/24"],
  "allowed_numbers": ["+919876543210"]
}
```

| LiveKit field | 60db field | Note |
| - | - | - |
| inbound trunk `name` | `name` | 1 to 64 characters |
| `numbers[0]` | `inbound_number` | E.164 with `+` |
| `numbers[1..]` | `numbers[{ number, agent_id }]` | Up to 50. `agent_id: null` means the line's agent. A number may carry its own agent, which LiveKit does with several dispatch rules |
| `allowed_addresses` | `allowed_addresses` | Same meaning: IPs or CIDR blocks, up to 64. Send them. See the warning below |
| `allowed_numbers` | `allowed_numbers` | Same meaning, E.164, up to 64 |
| `auth_username`, `auth_password` | not used for inbound | A 60db inbound call is **not** password-protected, only checked by source address |
| `krisp_enabled`, `ringing_timeout`, `headers`, `metadata` | no equivalent | Not exposed |
| `media_encryption` (SRTP) | not supported | See [What does not move](#what-does-not-move-over) |
| Dispatch rule `trunk_ids` | the phone line itself | A number belongs to one line, so nothing binds a rule to a trunk |
| Dispatch rule `dispatchRuleIndividual` / `roomPrefix` | nothing | There is no room: one call is one caller and one agent |
| Dispatch rule `roomConfig.agents[].agentName` | `agent_id` (per line or per number) | The agent that answers is attached to the number |
| Number written as `0…`, `00…` or without `+` | `normalize_numbers`, `default_country_code` | Off by default; exact match always wins |
| Transfer to a person (REFER) | `transfer_targets`, `bridge_transfers` | See [Transfers](https://docs.60db.ai/features/agents-bring-your-own-sip.md#transfers) |

> [!WARNING]
>
> **A trunk that relied on a login must switch to addresses.** If your LiveKit trunk was protected only by `auth_username` and `auth_password`, set `allowed_addresses` to the carrier's source IPs on the 60db line. A line with an inbound number and no `allowed_addresses` is accepted but is an **open line**: anyone can send calls that are billed to you (`warnings: [{ "code": "open_trunk" }]`).

### Outbound trunk and "create SIP participant"

LiveKit outbound trunk:

```json theme={null}
{
  "name": "Main outbound",
  "address": "sip.carrier.example",
  "transport": "SIP_TRANSPORT_AUTO",
  "numbers": ["+15550100"],
  "auth_username": "office-trunk",
  "auth_password": "…"
}
```

The same 60db phone line (add these to the line above, or create a separate outbound-only line with `inbound_number: null`):

```json theme={null}
{
  "kind": "byo",
  "name": "Main outbound",
  "domain": "sip.carrier.example",
  "username": "office-trunk",
  "password": "…",
  "caller_id": "+15550100",
  "inbound_number": null
}
```

| LiveKit field | 60db field | Note |
| - | - | - |
| `name` | `name` | |
| `address` | `domain` | Host, optionally with a port. Outbound is UDP, port 5060 unless the domain gives one |
| `transport` | not configurable | UDP only |
| `numbers` | `caller_id` | One number the carrier lets you present, E.164 |
| `auth_username`, `auth_password` | `username`, `password` | Optional, both or neither. The password is never returned |
| `destination_country`, `media_encryption` | no equivalent | Not exposed; no SRTP |
| `CreateSIPParticipant` (`sip_call_to`, `room_name`) | [Place call](https://docs.60db.ai/api-reference/agents/place-call.md): `to`, `phone_line_id` | No room. The call is saved first and returns `202` |

The carrier must accept calls from 60db's source addresses. See [Firewall and ports](https://docs.60db.ai/features/agents-bring-your-own-sip.md#firewall-and-ports).

## Move without risk

* **One number first.** Keep your current setup and give 60db a single number, or a test number. Nothing else changes.
* **Compare like with like.** Place the same ten calls on both and compare the answer delay (typical p50 and slow p95), failed calls, and what callers say. [Get call](https://docs.60db.ai/api-reference/agents/get-call.md) returns the measurements; the dashboard shows them under [call measurements](https://docs.60db.ai/features/agents.md#call-measurements). 60db's numbers depend on where it runs: measure from your own network, not a laptop. A workspace runs a limited number of [calls at once](https://docs.60db.ai/features/agents.md#limits), so size the test to it.
* **Widen in steps.** Move numbers one at a time. Use `numbers` to put several numbers on one phone line, each with its own agent.
* **Roll back by pointing the carrier back.** Your old trunk and worker stay untouched while you test, so undoing a step is changing where the carrier sends the number. Nothing in 60db has to be undone first. Prove it once before you rely on it.
* **Check the number is free first.** A number can answer in only one place. Moving it to 60db fails with `409 NUMBER_IN_USE` while it is still bound elsewhere in Agents, Dialer or a LiveKit realtime route. See [One number, one stack](https://docs.60db.ai/features/agents-bring-your-own-sip.md#one-number-one-stack).

## What does not move over

* **Shared rooms.** A 60db call is one caller and one agent. If you rely on rooms with several participants, keep LiveKit for that part. To put 60db speech models inside it, see the [LiveKit integration](https://docs.60db.ai/integrations/livekit.md).
* **Your own pipeline code.** Custom turn-taking, custom STT or TTS vendors and frame processors are not exposed: the engine owns them. The knobs that are exposed are the agent's settings.
* **TLS or SRTP on SIP.** 60db speaks SIP over UDP with plain RTP today. If your platform requires TLS or SRTP toward the target, terminate them at your own SBC or PBX, or use a stream call.
* **Stream calls and MCP or handoff.** Stream calls do not support MCP tools or agent handoff. SIP-only features (REFER, trunk source rules, recording through the carrier) also do not apply to them. Recording works as for any stream call.
* **Anything measured elsewhere.** Latency, concurrency and cost figures from your old setup do not carry over. Re-measure.

## Pipecat as the audio source (stream-call route)

Pipecat's Twilio serializer produces compatible `media` events, but it does not perform 60db's startup handshake. An adapter must send this JSON message when the stream-call socket opens, **before** forwarding Pipecat audio:

```json theme={null}
{"event":"start","start":{"mediaFormat":{"encoding":"audio/x-mulaw","sampleRate":8000,"channels":1}}}
```

Then forward the serializer's base64 µ-law `media` events at 8 kHz. The agent's replies come back as `media` events in the same style. Route them, and `clear` events (the caller cut in: drop audio you have not played), back through the pipeline. Without the startup message, sending audio first closes the socket with `1008`. 60db does the speech, model and voice itself, so Pipecat only carries the transport.

> [!WARNING]
>
> **Not qualified.** This adapter arrangement has **not been qualified against a real Pipecat pipeline**. The example below runs on its own against a real 60db stream call, using generated µ-law silence as the audio source. Wiring it to a Pipecat transport is shown in the comments and is untested.

```python theme={null}
"""Stream-call adapter: Pipecat-style media events <-> a 60db stream call.

pip install websockets requests
export SIXTYDB_API_KEY=... AGENT_ID=...
python stream_adapter.py
"""
import asyncio
import base64
import json
import os

import requests
import websockets

API = "https://api.60db.ai"
FRAME_SECONDS = 0.02  # 20 ms
MULAW_SILENCE = b"\xff" * 160  # 160 bytes of mu-law silence = 20 ms at 8 kHz

# Must be the first message on the socket, before any audio.
START_MESSAGE = {
    "event": "start",
    "start": {"mediaFormat": {"encoding": "audio/x-mulaw", "sampleRate": 8000, "channels": 1}},
}


def start_stream_call(agent_id: str) -> str:
    """Server side only: this call needs your API key. Hand only the returned URL onward."""
    r = requests.post(
        f"{API}/agents/{agent_id}/stream-calls",
        headers={"Authorization": f"Bearer {os.environ['SIXTYDB_API_KEY']}"},
        json={},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]["url"]  # single use, valid for 55 seconds


class StreamCallAdapter:
    """Opens the wss URL, sends the startup message, then forwards audio both ways.

    In a Pipecat pipeline, call `send_media(event)` with each Twilio-serializer media
    message (a dict) from your transport's output, and pass `on_audio` / `on_clear`
    callbacks that push agent audio and barge-in back into the pipeline.
    """

    def __init__(self, url, on_audio, on_clear, on_event=None):
        self.url = url
        self.on_audio = on_audio  # (mulaw_bytes) -> None
        self.on_clear = on_clear  # () -> None
        self.on_event = on_event or (lambda message: None)
        self._ws = None

    async def send_media(self, event: dict) -> None:
        """Forward one base64 mu-law `media` event from the serializer."""
        if event.get("event") != "media" or self._ws is None:
            return
        payload = event["media"]["payload"]
        await self._ws.send(json.dumps({"event": "media", "media": {"payload": payload}}))

    async def stop(self) -> None:
        """The caller hung up."""
        if self._ws is not None:
            await self._ws.send(json.dumps({"event": "stop"}))

    async def run(self, ready: asyncio.Event) -> dict | None:
        """Connect, handshake, then read replies until the call ends. Returns the summary."""
        async with websockets.connect(self.url) as ws:
            self._ws = ws
            await ws.send(json.dumps(START_MESSAGE))  # before forwarding any audio
            ready.set()
            summary = None
            async for raw in ws:
                if isinstance(raw, bytes):
                    continue  # this style carries audio as JSON media events
                message = json.loads(raw)
                kind = message.get("event")
                if kind == "media":
                    self.on_audio(base64.b64decode(message["media"]["payload"]))
                elif kind == "clear":
                    self.on_clear()
                elif kind == "ended":
                    summary = message.get("summary")
                    break
                else:
                    self.on_event(message)  # dtmf, transfer, hangup
            self._ws = None
            return summary


async def demo(agent_id: str, seconds: int = 8) -> None:
    played = 0

    def on_audio(chunk: bytes) -> None:
        nonlocal played
        played += len(chunk)

    adapter = StreamCallAdapter(
        start_stream_call(agent_id),
        on_audio=on_audio,
        on_clear=lambda: print("clear: drop buffered agent audio"),
        on_event=lambda m: print("event:", m.get("event")),
    )
    ready = asyncio.Event()
    runner = asyncio.create_task(adapter.run(ready))
    await ready.wait()

    silence = base64.b64encode(MULAW_SILENCE).decode()
    for _ in range(int(seconds / FRAME_SECONDS)):  # real-time pacing, a frame every 20 ms
        await adapter.send_media({"event": "media", "media": {"payload": silence}})
        await asyncio.sleep(FRAME_SECONDS)
    await adapter.stop()

    summary = await asyncio.wait_for(runner, timeout=30)
    print(f"agent audio received: {played} bytes; summary keys: {sorted(summary or {})}")


if __name__ == "__main__":
    asyncio.run(demo(os.environ["AGENT_ID"]))
```

Notes for a real pipeline:

* Send frames in real time, one every 20 ms, and keep sending silence while the caller is quiet. A gap reads as a dead line.
* Keep every message under 4 KB. Up to 3 stream calls run at once per workspace.
* An agent `transfer` message means "put the caller through on your side": answer with `{"event":"transfer_result","outcome":"joined"}` (or `busy`, `declined`, `no_answer`, `failed`) within 10 seconds. See the [stream call wire contract](https://docs.60db.ai/api-reference/agents/start-stream-call.md#the-websocket) and [Agent Stream WebSocket](https://docs.60db.ai/websocket-api/agent-stream.md).

## Checklist

* [ ] Agent saved, tools declared, test call passes (and test script, once agent tests reach you).
* [ ] Phone line created with the carrier's `allowed_addresses` and the number; the dry-run check answers `200`; a real test call is answered by the agent.
* [ ] Transfers tried (turn on `bridge_transfers` if the carrier ignores REFER); a warm one if you use it.
* [ ] Ten calls compared with the old setup on typical and slow answer delay and failed calls.
* [ ] Webhooks, recordings and transcripts land where your systems expect them.
* [ ] Carrier pointed back at the old setup once, to prove the rollback.

## Related

* [Bring your own SIP](https://docs.60db.ai/features/agents-bring-your-own-sip.md)
* [Knowledge for agents](https://docs.60db.ai/features/agents-knowledge.md)
* [Agents](https://docs.60db.ai/features/agents.md), [Tools and webhooks](https://docs.60db.ai/features/agents-tools-and-webhooks.md) and [Agents MCP tools](https://docs.60db.ai/mcp-server/agents.md)
* [LiveKit integration](https://docs.60db.ai/integrations/livekit.md): 60db speech models inside LiveKit Agents (a different topic)
* [Names: HelloKit and 60db](https://docs.60db.ai/features/agents-glossary.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.