---
title: "Local Mode"
description: "Run a fully self-hosted voice AI server with your own telephony and AI provider keys."
icon: "server"
---

# Local Mode (Self-Hosted)

Local mode runs an embedded server on your infrastructure. You bring your own telephony credentials (Twilio or Telnyx) and AI provider keys. No Patter backend required.

## When to Use Local Mode

- You need full control over your infrastructure
- You want to keep all data on your own servers
- You are testing and developing locally
- You need to comply with data residency requirements

## Setup

### 1. Install the SDK

```bash
pip install getpatter
```

### 2. Expose your server

Telephony providers need a public URL to reach your local server. The easiest option is the **built-in Cloudflare tunnel** — pass `tunnel=True` (or `tunnel=CloudflareTunnel()`) to `serve()` and Patter creates the tunnel and auto-configures the Twilio webhook for you (requires the `cloudflared` package, see [Tunneling](/dev-tools/tunneling)).

Alternatively, run [ngrok](https://ngrok.com) and pass the hostname as `webhook_url`:

```bash
ngrok http 8000
```

### 3. Initialize Patter

```python
import os
from dotenv import load_dotenv
from getpatter import Patter, Twilio

load_dotenv()

phone = Patter(
    carrier=Twilio(),                                    # TWILIO_* from env
    phone_number=os.environ["PHONE_NUMBER"],
    webhook_url=os.environ["WEBHOOK_URL"],
)
```

---

## serve()

The `serve()` method starts the embedded server and blocks until it is stopped. It handles inbound calls automatically.

```python
await phone.serve(agent, port=8000)
```

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `agent` | `Agent` | *required* | The agent configuration to use for all calls. Create with `phone.agent()`. |
| `port` | `int` | `8000` | TCP port to bind to (1-65535). |
| `recording` | `bool` | `False` | Enable call recording via the Twilio Recordings API. |
| `on_call_start` | `Callable \| None` | `None` | Async callback fired when a call connects. |
| `on_call_end` | `Callable \| None` | `None` | Async callback fired when a call ends. |
| `on_transcript` | `Callable \| None` | `None` | Async callback fired for each utterance. |
| `on_message` | `Callable \| None` | `None` | Async callback for pipeline mode. Receives user text, returns agent response. |
| `voicemail_message` | `str` | `""` | Message to speak when AMD detects a machine on outbound calls. |
| `on_metrics` | `Callable \| None` | `None` | Async callback fired after each conversation turn with real-time cost and latency data. See [Metrics & Cost Tracking](/python-sdk/metrics). |
| `dashboard` | `bool` | `True` | Serve a local metrics dashboard at `http://localhost:{port}/dashboard`. |
| `dashboard_token` | `str` | `""` | Bearer token for dashboard authentication. When set, all dashboard routes require this token. |
| `tunnel` | `bool` | `False` | Start a cloudflared tunnel automatically. Requires `cloudflared` on PATH. Mutually exclusive with `webhook_url`. |

---

## Making Outbound Calls

In local mode, use `call()` to make outbound calls while the server is running.

**Important:** the server must be fully initialized before you call `phone.call()`. Use `await phone.ready` — it resolves once the tunnel is up, the embedded server is in `listen` state, and the carrier webhook is configured. This is the reliable replacement for `asyncio.sleep()` guesswork:

```python
import asyncio

async def main():
    # Start the server in the background — don't await it yet.
    server_task = asyncio.create_task(phone.serve(agent, port=8000))

    # Block until tunnel + HTTP server + carrier webhook are all wired up.
    # Resolves to the public webhook hostname as a string.
    await phone.ready

    try:
        # Now safe to make outbound calls
        await phone.call(
            to="+15550009876",
            agent=agent,
            machine_detection=True,
            voicemail_message="Please call us back at 555-000-1234.",
        )
    finally:
        # Clean shutdown
        await phone.disconnect()
        server_task.cancel()

asyncio.run(main())
```

`phone.ready` rejects with the underlying exception if `serve()` fails before the server reaches `listen` state, so you get an immediate error instead of a hanging `await`.

**Advanced — tunnel hostname only:** if you only need the public URL (for example, to register a webhook manually) without waiting for the HTTP server to be in `listen` state, use `await phone.tunnel_ready` instead. It resolves earlier but a `phone.call()` placed immediately afterwards can race the WebSocket upgrade path and produce a dropped call on answer.

### call() Parameters (Local Mode)

All keyword arguments are **snake_case** (e.g. `machine_detection=`, `ring_timeout=`, `on_machine_detection=`).

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `to` | `str` | *required* | Phone number to call (E.164 format). |
| `agent` | `Agent` | *required* | Agent instance to use for this call. |
| `first_message` | `str` | `""` | What the AI says when the callee answers. |
| `from_number` | `str` | `""` | Override the configured phone number. |
| `machine_detection` | `bool` | `True` | Enable answering machine detection. Defaults **on** since 0.6.2 — on Twilio Patter uses `MachineDetection=DetectMessageEnd` + Async AMD so there is no answer-latency penalty on human pickups. Pass `False` to skip per-call AMD billing for known destinations. |
| `on_machine_detection` | `Callable[[MachineDetectionResult], Awaitable[None] \| None] \| None` | `None` | Fires once when the carrier reports the AMD outcome (`human` or `machine`). |
| `voicemail_message` | `str` | `""` | Message to leave on voicemail. A non-empty value also implicitly enables `machine_detection`. |
| `ring_timeout` | `int \| None` | `25` | Ring timeout in seconds before treating the call as no-answer. Defaults to 25 s — production-recommended. Pass `60` for legacy carrier-default parity, or `None` to omit the parameter entirely (carrier picks its own default). |

---

## EmbeddedServer

The `EmbeddedServer` is the internal class that powers `serve()`. You typically do not interact with it directly, but it is useful to understand for advanced use cases.

| Property | Type | Description |
|----------|------|-------------|
| `config` | `LocalConfig` | Telephony and AI provider configuration. |
| `agent` | `Agent` | The agent configuration. |
| `recording` | `bool` | Whether call recording is enabled. |
| `voicemail_message` | `str` | Voicemail message for AMD. |

### Methods

| Method | Description |
|--------|-------------|
| `start(port)` | Start the server (blocking). |
| `stop()` | Gracefully stop the server. |

---

## LocalConfig

The `LocalConfig` dataclass holds all provider credentials for local mode. It is created automatically from the `Patter` constructor — you don't normally need to touch it directly.

| Field | Type | Description |
|-------|------|-------------|
| `telephony_provider` | `str` | `"twilio"` or `"telnyx"` (auto-detected from the `carrier=` instance). |
| `twilio_sid` | `str` | Twilio Account SID (unpacked from `Twilio(...)`). |
| `twilio_token` | `str` | Twilio Auth Token (unpacked from `Twilio(...)`). |
| `telnyx_key` | `str` | Telnyx API key (unpacked from `Telnyx(...)`). |
| `telnyx_connection_id` | `str` | Telnyx Call Control Application ID (unpacked from `Telnyx(...)`). |
| `telnyx_public_key` | `str` | Telnyx Ed25519 public key for webhook signature verification (optional). |
| `openai_key` | `str` | OpenAI API key (resolved from `OpenAIRealtime(...)` or `OPENAI_API_KEY`). |
| `elevenlabs_key` | `str` | ElevenLabs API key. |
| `deepgram_key` | `str` | Deepgram API key. |
| `cartesia_key`, `rime_key`, `lmnt_key`, `soniox_key`, `speechmatics_key`, `assemblyai_key` | `str` | Provider-specific keys backfilled from the matching constructor or env var. |
| `phone_number` | `str` | Phone number in E.164 format. |
| `webhook_url` | `str` | Public hostname (no scheme). |
| `require_signature` | `bool` | When `True` (default), inbound webhooks with missing credentials return HTTP 503 instead of silently accepting. Disable only for local mock-provider testing. |
| `persist_root` | `str \| None` | Resolved persistence path for the dashboard's on-disk call history, or `None` to disable. Set by the `persist=` argument on `Patter(...)` (with `PATTER_LOG_DIR` env fallback). |

---

## Disconnecting

Call `disconnect()` to stop the embedded server gracefully:

```python
await phone.disconnect()
```

---

## Complete Example

```python
import os
import asyncio
from dotenv import load_dotenv
from getpatter import Patter, Twilio, OpenAIRealtime, Tool, Guardrail

load_dotenv()

phone = Patter(
    carrier=Twilio(),                                   # TWILIO_* from env
    phone_number=os.environ["PHONE_NUMBER"],
    webhook_url=os.environ["WEBHOOK_URL"],
)

async def schedule_appointment(args: dict, ctx: dict) -> dict:
    # Your reservation system here.
    return {"confirmation": "ACME-123", "date": args["date"], "time": args["time"]}

agent = phone.agent(
    engine=OpenAIRealtime(voice="nova"),                # OPENAI_API_KEY from env
    system_prompt="""You are a friendly receptionist for Acme Corp.
Help callers schedule appointments, answer general questions, and transfer to the right department.
Transfer billing questions to +15550001111.
Transfer technical support to +15550002222.""",
    first_message="Thank you for calling Acme Corp! How can I help you today?",
    tools=[
        Tool(
            name="schedule_appointment",
            description="Schedule an appointment for the caller.",
            parameters={
                "type": "object",
                "properties": {
                    "name": {"type": "string", "description": "Caller's full name"},
                    "date": {"type": "string", "description": "Preferred date (YYYY-MM-DD)"},
                    "time": {"type": "string", "description": "Preferred time (HH:MM)"},
                    "reason": {"type": "string", "description": "Reason for appointment"},
                },
                "required": ["name", "date", "time"],
            },
            handler=schedule_appointment,
        ),
    ],
    guardrails=[
        Guardrail(
            name="No legal advice",
            blocked_terms=["lawsuit", "sue", "legal action"],
            replacement="I'm not able to provide legal guidance. Let me transfer you to our legal department.",
        ),
    ],
)

async def on_call_start(event):
    print(f"[CALL START] {event['direction']} call {event['call_id']}")
    print(f"  From: {event['caller']} -> To: {event['callee']}")

async def on_call_end(event):
    print(f"[CALL END] {event['call_id']}")
    for entry in event["transcript"]:
        print(f"  [{entry['role']}]: {entry['text']}")

async def on_transcript(event):
    print(f"  [{event['role']}]: {event['text']}")

async def main():
    await phone.serve(
        agent,
        port=8000,
        recording=True,
        on_call_start=on_call_start,
        on_call_end=on_call_end,
        on_transcript=on_transcript,
        voicemail_message="Hi, this is Acme Corp. Please call us back at your earliest convenience.",
    )

asyncio.run(main())
```
