---
title: "Local Mode"
description: "Self-hosted embedded server, webhook endpoints, and WebSocket streams."
icon: "server"
---

# Local Mode

Local mode runs an embedded Express server on your infrastructure. It handles telephony webhooks, manages WebSocket audio streams, and connects to AI providers directly — no external service required.

## Starting the Server

```typescript
// npx tsx example.ts
import { Patter, Twilio, OpenAIRealtime } from "getpatter";

const phone = new Patter({
  carrier: new Twilio(),                              // TWILIO_* from env
  phoneNumber: "+15550001234",
  webhookUrl: "abc.ngrok.io",
});

const agent = phone.agent({
  engine: new OpenAIRealtime(),                       // OPENAI_API_KEY from env
  systemPrompt: "You are a helpful assistant.",
});

await phone.serve({ agent, port: 8000 });
```

## ServeOptions

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `agent` | `Agent` | Yes | — | Agent configuration from `phone.agent(...)`. |
| `port` | `number` | No | `8000` | Port for the embedded server (1-65535). |
| `tunnel` | `boolean` | No | `false` | When `true`, start a cloudflared tunnel automatically (requires `cloudflared` npm package). Cannot be used together with `webhookUrl`. |
| `onCallStart` | `(data) => Promise<void>` | No | — | Called when a call connects. |
| `onCallEnd` | `(data) => Promise<void>` | No | — | Called when a call disconnects. |
| `onTranscript` | `(data) => Promise<void>` | No | — | Called for each transcript segment. |
| `onMessage` | `(data) => Promise<string>` | No | — | Pipeline mode only. Receives user transcript, returns response text. Can also be a URL string for remote webhook/WebSocket integration. |
| `onMetrics` | `(data) => Promise<void>` | No | — | Called after each conversational turn with per-turn latency and cost metrics. |
| `recording` | `boolean` | No | `false` | Enable call recording (Twilio only). |
| `localRecording` | `boolean \| string` | No | `false` | Carrier-neutral SDK-side recording: stereo WAV per call (left = caller, right = agent, PCM16 16 kHz). Pass a directory string to set the output directory. See [Local Recording](/typescript-sdk/features#local-recording-carrier-neutral). |
| `voicemailMessage` | `string` | No | — | Default voicemail message for AMD. |
| `pricing` | `Record<string, Record<string, unknown>>` | No | — | Custom pricing overrides for cost calculation. |
| `dashboard` | `boolean` | No | `true` | When `true`, serve a dashboard UI at `/dashboard`. |
| `dashboardToken` | `string` | No | — | Bearer token for dashboard/API authentication. |
| `manageWebhook` | `boolean` | No | `true` | When `true`, `serve()` updates the carrier's webhook URL (Twilio `voice_url`) on startup. Set to `false` if the webhook is managed externally (Terraform, an edge gateway, or a router function in front of the agent) — otherwise every boot will silently overwrite the externally-managed value. Ignored when `tunnel: true`, because the tunnel hostname is dynamic and only known at runtime. |

## Webhook Endpoints

The embedded server exposes these HTTP endpoints:

### Health Check

```
GET /health
```

Returns `{ "status": "ok", "mode": "local" }`.

### Twilio Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/webhooks/twilio/voice` | POST | Handles incoming calls. Returns TwiML to connect a media stream. |
| `/webhooks/twilio/recording` | POST | Receives recording status callbacks. |
| `/webhooks/twilio/amd` | POST | Receives AMD (answering machine detection) results. |
| `/webhooks/twilio/status` | POST | Receives call status callbacks for outbound calls. |

<Info>Telnyx endpoints are fully supported with feature parity to Twilio (DTMF, transfer, recording).</Info>

### Telnyx Endpoints

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/webhooks/telnyx/voice` | POST | Handles incoming calls. Returns commands to answer and start streaming. |

## WebSocket Streams

Audio streams are handled over WebSocket. The server upgrades HTTP connections to WebSocket on the `/ws/stream/` path and each call gets its own connection. How `caller` and `callee` reach the handler depends on the carrier:

- **Twilio** — the `wss://{webhookUrl}/ws/stream/{callId}` URL has no query string. Twilio strips query-string params during the WS upgrade handshake, so the inbound TwiML emits the caller / callee as `<Parameter name="caller" value="..."/>` / `<Parameter name="callee" value="..."/>` children of `<Stream>`. The values are then surfaced in the WS `start` frame as `start.customParameters` and applied by `StreamHandler.handleCallStart`.
- **Telnyx** — `wss://{webhookUrl}/ws/stream/{callControlId}?caller={caller}&callee={callee}`. The Call Control flow includes the metadata in the answer command and the SDK reads the query string on the WS upgrade.

If you construct the inbound TwiML yourself (rather than letting Patter's `/webhooks/twilio/voice` route emit it), use `TwilioAdapter.generateStreamTwiml(streamUrl, { caller, callee })` — the `parameters` argument is forwarded as `<Parameter>` children of `<Stream>` so it lands on `start.customParameters`.

### Rate Limiting

WebSocket connections are rate-limited to **10 concurrent connections per IP address**. Connections exceeding the limit receive a `429 Too Many Requests` response. This protects against DoS attacks while being generous enough for legitimate telephony provider traffic (which only opens 1 connection per call).

## Security

### Twilio Signature Validation

When a Twilio Auth Token is configured (either passed to `new Twilio({ authToken })` or via `TWILIO_AUTH_TOKEN`), all Twilio webhook requests are validated using HMAC-SHA1 signature verification:

1. The SDK reconstructs the URL from the webhook hostname and request path
2. Parameters are sorted and concatenated
3. An HMAC-SHA1 digest is computed using the Twilio Auth Token
4. The result is compared with the `X-Twilio-Signature` header using timing-safe comparison

Requests with invalid signatures are rejected with HTTP 403.

### Telnyx Signature Validation

When a Telnyx public key is configured (either passed to `new Telnyx({ publicKey })` or via `TELNYX_PUBLIC_KEY`), Telnyx webhook requests are verified using Ed25519 signatures:

1. The raw request body is captured before JSON parsing
2. The signed payload is: `{timestamp}|{rawBody}`
3. The Ed25519 signature is verified against the Telnyx public key
4. Requests older than 5 minutes are rejected (replay protection)

Requests with invalid signatures are rejected with HTTP 403.

## Architecture

```
Phone Call
    │
    ▼
Telephony Provider (Twilio/Telnyx)
    │
    ├── POST /webhooks/{provider}/voice  →  Returns stream instructions
    │
    ▼
WebSocket /ws/stream/{callId}
    │
    ├── Audio In  → [Transcoding] → AI Provider (OpenAI/ElevenLabs/Pipeline)
    │
    └── Audio Out ← [Transcoding] ← AI Provider
    │
    ▼
Phone Speaker
```

### Audio Transcoding

| Provider | Input Format | Transcoding |
|----------|-------------|-------------|
| Twilio | mulaw 8kHz | Decoded to PCM 16kHz |
| Telnyx | PCM 16kHz | No transcoding needed |
| OpenAI TTS | PCM 24kHz | Resampled to 16kHz |

## Graceful Shutdown

Call `phone.disconnect()` to gracefully stop the embedded server. The shutdown sequence:

1. **Stop accepting new connections** — the HTTP server stops listening.
2. **Hang up active calls** — each active call is terminated via the telephony provider API (Twilio, Telnyx, or Plivo).
3. **Close WebSocket connections** — a close frame (`1001 Server shutting down`) is sent to all active WebSocket connections.
4. **Wait for drain** — the server waits up to **10 seconds** for active connections to close cleanly.
5. **Force-terminate** — any connections still open after the drain timeout are forcibly terminated.
6. **Close HTTP server** — the underlying HTTP server is fully closed.

```typescript
// Handle SIGINT/SIGTERM for clean shutdown
process.on("SIGINT", async () => {
  console.log("Shutting down...");
  await phone.disconnect();
  process.exit(0);
});
```

<Info>
  If a cloudflared tunnel was started with `tunnel: true`, it is also stopped when `disconnect()` is called.
</Info>

## Binding Address

The server binds to `0.0.0.0` by default, which means it listens on all network interfaces. This is necessary for the server to accept connections from telephony providers and tunnels, but it also means the server is accessible from other machines on your network.

<Warning>
Because the server binds to `0.0.0.0`, it is reachable from any network interface. In production, use a firewall or reverse proxy to restrict access. Always configure the Auth Token / public key so webhook signature verification is enforced.
</Warning>

Use a tunnel (Cloudflare Tunnel, ngrok) to expose the server to the internet for telephony provider webhooks.
