This is the full developer documentation for OpenLine # OpenLine > Your Android phone's SIM, as a voice API. Place and answer real phone calls over REST, and put AI voice agents on the line through WebSockets. Open source and self-hosted. [![](/openline/media/openline-explainer-poster.jpg)](/openline/media/openline-explainer.mp4) REST API for phone calls `POST /v1/calls` dials. Answer, hang up, hold and press keys with one request each. Every state change arrives as a signed webhook. AI voice agents on real calls Give a call a `stream` URL and OpenLine connects it to your agent’s WebSocket. Twilio Media Streams format included, so Pipecat-style agents work as they are. Your SIM, your server Calls go out on a normal SIM plan, with no per-minute API fees. The server runs on your machine; calls, transcripts and settings stay in one SQLite file. Silent phone The phone only routes the call. Audio flows between the caller and your server; nothing plays on the handset. No root, no extra hardware. ## Place a call with an AI agent on it [Section titled “Place a call with an AI agent on it”](#place-a-call-with-an-ai-agent-on-it) ```bash curl -X POST https://your-openline.example/v1/calls \ -H "Authorization: Bearer $OPENLINE_KEY" -H "content-type: application/json" \ -d '{"deviceId":"phone-7f3a91c2","to":"+919876543210", "stream":{"url":"wss://my-agent.example.com/ws","format":"twilio"}}' ``` When the call is answered, OpenLine opens your agent’s WebSocket and streams the conversation both ways. Your agent hangs up by closing the socket. ## Why OpenLine [Section titled “Why OpenLine”](#why-openline) * **A self-hosted Twilio alternative** for voice: the same building blocks (calls API, status webhooks, media streams), running on hardware you own. * **Built for AI agents:** Pipecat, LiveKit workers or your own speech pipeline get raw 16 kHz PCM or Twilio-compatible audio. * **Local numbers:** calls come from a real local SIM, so they look like any other call. Building with an AI coding assistant? Point it at [`llms.txt`](llms.txt) or [`llms-full.txt`](llms-full.txt). # Concepts > Phones, calls, events, streams and keys, in one page. ## Phones (devices) [Section titled “Phones (devices)”](#phones-devices) A phone is an Android handset running the OpenLine app and paired with your server. Each has an id like `phone-7f3a91c2`, which you pass as `deviceId` when placing a call. A phone holds a WebSocket to the server; while it’s open the phone is `connected`. The server pings it every 20 seconds and shows its response time and health (battery, network, signal, call-audio helper). ## Calls [Section titled “Calls”](#calls) A call has an `id`, a `direction` (`outgoing` or `incoming`), the `remoteNumber`, and a `state` that moves through `queued → dialing → active → ended` (or `ringing`, `held`, `rejected`, `failed`). Incoming calls appear on their own as soon as the phone rings. Calls are stored, so `GET /v1/calls` is your call log. [States and events](../../reference/call-states/). ## Commands are asynchronous [Section titled “Commands are asynchronous”](#commands-are-asynchronous) `POST /v1/calls`, `/answer`, `/hangup` and the other actions return `202` when the **phone has received** the command, not when the carrier has done it. The real outcome arrives as an event: `call.active` when someone picks up, `call.disconnected` when it ends. Build on events, not on the `202`. ## Events and webhooks [Section titled “Events and webhooks”](#events-and-webhooks) The phone reports every change as an event (`call.ringing`, `call.active`, …). The server updates the call, stores the event, and POSTs it to your webhook: the call’s own `webhookUrl`, or the default one in Settings. Deliveries are signed and retried. [Webhooks](../../guides/webhooks/). ## Audio [Section titled “Audio”](#audio) Call audio is 16 kHz mono 16-bit PCM in 20 ms frames. There are two ways to use it: * **Streams (recommended for agents):** give the call a `stream` URL and the gateway connects to your agent’s WebSocket when the call is answered, in raw or Twilio format. [Connect an AI agent](../../guides/agent-streams/). * **Media WebSocket:** your code connects to `/v1/calls/{id}/media` itself. [Join call audio yourself](../../guides/media/). ## API keys and accounts [Section titled “API keys and accounts”](#api-keys-and-accounts) Programs authenticate with **API keys** (`Authorization: Bearer gw_...`), created in Settings, one per app so you can revoke them separately. The key in `server/.env` always works too. People sign in to the **console** with an email and password; that login isn’t used by the API. # Introduction > What OpenLine is: an open-source, self-hosted voice API that turns an Android phone into a programmable phone line for AI voice agents. How it works and its limits. OpenLine turns an Android phone with a SIM card into a programmable phone line. Your code places and answers real calls through a REST API, receives call events by webhook, and streams call audio over WebSockets. It’s a small, self-hosted alternative to Twilio Voice, built for putting AI voice agents on real phone calls. ## How it works [Section titled “How it works”](#how-it-works) ```text Caller's phone ── mobile network ── Android phone (OpenLine app) │ WebSocket (commands, events, audio) ▼ OpenLine server ── webhooks ──▶ your backend │ REST + WebSocket ▼ your code / your AI agent (Pipecat, LiveKit worker, custom) ``` * **The phone** runs the OpenLine app. It’s the default phone app, so it can dial, answer and hang up. It captures the call’s audio and plays your audio into the call, and stays silent itself. * **The server** runs on your machine. It exposes the API, relays audio, sends webhooks and stores everything in SQLite. A tunnel (Tailscale Funnel or Cloudflare Tunnel) gives it a public `https://` address so the phone can reach it from any network. * **Your code** uses the API, or lets the server connect calls straight to your agent’s WebSocket. ## What you can build [Section titled “What you can build”](#what-you-can-build) * AI agents that make outbound calls (reminders, follow-ups, surveys) and hang up when done. * An AI receptionist that answers your number, asks why people are calling and takes messages. * Call automation in plain code: dial, press menu keys, listen, hang up. * Testing voice agents against real phone lines. ## What it isn’t [Section titled “What it isn’t”](#what-it-isnt) OpenLine is a v1. It does calls well and leaves out the rest: * **One line per phone.** Each phone handles one call at a time on its own number. For more parallel calls, pair more phones. * **No SMS, recording, conferencing or call flows (TwiML).** Not yet. * **Not carrier scale.** It’s built for one person or a small team, not thousands of calls. * **Calls cost what your SIM plan charges.** There’s no billing layer. * **Tested on Android 16 (Vivo).** Call audio relies on Android behaviour that varies by vendor. See [Set up the phone](../../guides/phone/). ## Next [Section titled “Next”](#next) * [Quickstart](../quickstart/): run the server, pair a phone and place your first call. * [Connect an AI agent](../../guides/agent-streams/): the main use case. # Quickstart > Run the OpenLine server, pair an Android phone and place your first API phone call in about 15 minutes. You need a Windows, macOS or Linux machine with **Node.js 22.13 or newer**, and an Android phone (Android 11+) with a SIM that can make calls. ## 1. Run the server [Section titled “1. Run the server”](#1-run-the-server) ```bash git clone cd openline/server cp .env.example .env ``` Edit `.env` and set `HTTP_GATEWAY_API_KEY` to a long random string. Then: ```bash npm install npm run build npm start ``` On Windows you can double-click `run-server.bat` instead: it does all of the above and also opens Tailscale Funnel. Open `http://localhost:8787/`. Create your account (email and password). This is the console. ## 2. Give it a public address [Section titled “2. Give it a public address”](#2-give-it-a-public-address) The phone has to reach the server from mobile data, so the server needs an `https://` address. The quickest is Tailscale Funnel: ```bash tailscale funnel --bg 8787 ``` Copy the address it prints (like `https://your-pc.your-tailnet.ts.net`) into the console under **Settings → Server → Public address**. [Other options](../../self-hosting/tunnels/). ## 3. Pair the phone [Section titled “3. Pair the phone”](#3-pair-the-phone) 1. Install the OpenLine APK on the phone. 2. In the console, open **Connection**. A QR code appears. 3. In the app, work through the setup steps: 1. **Default phone app.** Tap **Set**. 2. **Connect to your gateway.** Tap **Scan QR** and scan the code. 3. **Call audio.** Tap **Set up**, turn on Wireless debugging, tap **Pair device with pairing code**, and type the code into the app’s notification. This needs Wi-Fi. 4. **Stay online in the background.** Tap **Allow**. The phone appears on the Connection page as online. [More on phone setup](../../guides/phone/). ## 4. Place a call [Section titled “4. Place a call”](#4-place-a-call) Create an API key under **Settings → API keys**, then: ```bash export OPENLINE_URL=https://your-pc.your-tailnet.ts.net export OPENLINE_KEY=gw_... # find your phone's id curl -s $OPENLINE_URL/v1/devices -H "Authorization: Bearer $OPENLINE_KEY" curl -X POST $OPENLINE_URL/v1/calls \ -H "Authorization: Bearer $OPENLINE_KEY" -H "content-type: application/json" \ -d '{"deviceId":"phone-7f3a91c2","to":"+919876543210"}' ``` The phone dials. Watch the call in the console’s **Playground**, where you can also talk from your laptop. End it with: ```bash curl -X POST $OPENLINE_URL/v1/calls//hangup -H "Authorization: Bearer $OPENLINE_KEY" ``` ## Next [Section titled “Next”](#next) * [Connect an AI agent](../../guides/agent-streams/) to the call. * [Get webhooks](../../guides/webhooks/) for call events. # Connect an AI agent > Connect AI voice agents (Pipecat, LiveKit workers, custom code) to real phone calls. OpenLine streams calls to your WebSocket in raw PCM or Twilio Media Streams format. The simplest way to put an AI agent on a phone call: tell the gateway where your agent listens, and it connects the call to it. Your agent runs a WebSocket server; the gateway is the client, just like Twilio’s ``. ## Outgoing calls [Section titled “Outgoing calls”](#outgoing-calls) Add `stream` when placing the call: ```bash curl -X POST $OPENLINE_URL/v1/calls \ -H "Authorization: Bearer $OPENLINE_KEY" -H "content-type: application/json" \ -d '{ "deviceId": "phone-7f3a91c2", "to": "+919876543210", "stream": { "url": "wss://agent.example.com/ws", "format": "twilio", "parameters": { "customerId": "c_123" } } }' ``` When the callee answers, the gateway opens `wss://agent.example.com/ws` and streams the call. Nothing connects if nobody picks up. ## Incoming calls [Section titled “Incoming calls”](#incoming-calls) In the console, **Settings → Incoming calls → Stream to your agent**, or by API: ```bash curl -X PUT $OPENLINE_URL/v1/settings \ -H "Authorization: Bearer $OPENLINE_KEY" -H "content-type: application/json" \ -d '{"incomingStreamUrl":"wss://agent.example.com/ws","incomingStreamFormat":"twilio"}' ``` Every incoming call is answered after about a second and connected to your agent. The built-in assistant stays out of these calls. Clear the URL (`""`) to stop. ## A call that’s already live [Section titled “A call that’s already live”](#a-call-thats-already-live) Attach an agent to any live call, for example one your code answered after a webhook: ```bash curl -X POST $OPENLINE_URL/v1/calls/$CALL_ID/stream \ -H "Authorization: Bearer $OPENLINE_KEY" -H "content-type: application/json" \ -d '{"url":"wss://agent.example.com/ws","format":"raw"}' ``` A ringing incoming call is answered first. `GET` the same path for the status (`waiting`, `connecting`, `live`, `ended`, `error`); `DELETE` disconnects your agent but keeps the call. ## Ending the call [Section titled “Ending the call”](#ending-the-call) Close the WebSocket. The gateway hangs up. If your agent can’t be reached at all, the gateway also hangs up, so a caller is never left in silence. When the other person hangs up, your agent gets a `stop` message and the socket closes. ## Formats [Section titled “Formats”](#formats) | | `raw` | `twilio` | | ---------------- | --------------------------------------------------- | ------------------------------------------ | | Audio | Binary frames, 16 kHz mono s16le, 20 ms (640 bytes) | JSON `media` messages, base64 8 kHz mu-law | | Start | `{"type":"start", ...}` | `connected`, then `start` | | Interrupt | `{"type":"clear"}` | `{"event":"clear"}` | | Playback markers | — | `mark`, echoed back once played | | Best for | Your own code, best quality | Pipecat and anything built for Twilio | Full message shapes: [Stream protocol](../../reference/stream-protocol/). ## Example: Pipecat [Section titled “Example: Pipecat”](#example-pipecat) Pipecat’s Twilio integration reads the `connected` and `start` messages and speaks the Twilio format, so point your Pipecat WebSocket endpoint at the gateway with `"format": "twilio"`. The start message carries the gateway call id as `callSid` and your `parameters` as `customParameters`. ```python # FastAPI endpoint for a Pipecat bot (names follow Pipecat's Twilio example; check the # Pipecat docs for your version). @app.websocket("/ws") async def ws(websocket: WebSocket): await websocket.accept() _, call_data = await parse_telephony_websocket(websocket) # reads "connected" + "start" serializer = TwilioFrameSerializer( stream_sid=call_data["stream_id"], call_sid=call_data["call_id"], # No Twilio account here: let the gateway hang up when the socket closes. params=TwilioFrameSerializer.InputParams(auto_hang_up=False), ) await run_bot(websocket, serializer) # your STT -> LLM -> TTS pipeline ``` When your pipeline ends and closes the socket, the call hangs up. ## Example: raw format in Node [Section titled “Example: raw format in Node”](#example-raw-format-in-node) ```js import { WebSocketServer } from "ws"; new WebSocketServer({ port: 9000 }).on("connection", (ws) => { ws.on("message", (data, isBinary) => { if (!isBinary) { const msg = JSON.parse(data); if (msg.type === "start") console.log("call", msg.callId, "from", msg.remoteNumber); return; } // data: 20 ms of the caller, 16 kHz s16le. Send your agent's speech back the same way: // ws.send(pcmBuffer, { binary: true }) }); }); ``` A complete echo agent for both formats is in `simulator/echo-agent.mjs`. ## Tips [Section titled “Tips”](#tips) * **Latency:** send audio as soon as you have it. The gateway splits it into 20 ms frames and the phone buffers it. * **Barge-in:** when the caller starts talking over your agent, send `clear` to drop speech that hasn’t played yet. * **Exposing a local agent:** your agent needs an address the server can reach. If the agent runs on the same machine as the server, `ws://127.0.0.1:9000` works. * **Who’s calling:** the start message includes `callId`, `direction`, `remoteNumber`, `deviceId` and your `parameters`. # Built-in AI assistant > A Gemini Live voice assistant that answers calls for you and takes messages. The gateway includes a ready-made assistant on Google’s Gemini Live API. It’s a personal receptionist: it answers, says it’s your assistant, asks who’s calling and why, offers to take a message, and hangs up politely. For anything custom, use [your own agent](../agent-streams/) instead. ## Turn it on [Section titled “Turn it on”](#turn-it-on) 1. Get a Gemini API key at aistudio.google.com. 2. Console **Settings → AI assistant**: paste the key and save. (Or `GEMINI_API_KEY` in `server/.env`.) 3. Console **Playground → Assistant**: set **Answers on behalf of** to your name, optionally fill **About you**, and turn on **Answer incoming calls for me**. **About you** is what the assistant may tell callers (“Alex is travelling until Monday; for the bike service, he’ll pick it up Saturday”). Write it in the third person and include only what you’re happy for any caller to hear. ## On a single call [Section titled “On a single call”](#on-a-single-call) ```bash curl -X POST $OPENLINE_URL/v1/calls/$CALL_ID/agent -H "Authorization: Bearer $OPENLINE_KEY" # hand over curl -X DELETE $OPENLINE_URL/v1/calls/$CALL_ID/agent -H "Authorization: Bearer $OPENLINE_KEY" # take back curl $OPENLINE_URL/v1/calls/$CALL_ID/agent -H "Authorization: Bearer $OPENLINE_KEY" # status + live transcript ``` ## Messages [Section titled “Messages”](#messages) When a caller leaves a message, it’s saved with their name, reason, callback number and urgency: ```bash curl $OPENLINE_URL/v1/agent/messages -H "Authorization: Bearer $OPENLINE_KEY" ``` The full conversation is at `GET /v1/calls/{id}/transcript`. ## Settings [Section titled “Settings”](#settings) `GET /v1/agent` / `PUT /v1/agent` with `autoAnswer`, `ownerName`, `about` and `voice` (a Gemini voice name such as `Kore`, `Puck` or `Charon`). The model can be changed in Settings. # Place and control calls > Dial, answer, hang up, hold, press keys, and read the call log. All requests need `Authorization: Bearer `. Full schemas are in the [API reference](../../api/). ## Place a call [Section titled “Place a call”](#place-a-call) ```bash curl -X POST $OPENLINE_URL/v1/calls \ -H "Authorization: Bearer $OPENLINE_KEY" -H "content-type: application/json" \ -d '{ "deviceId": "phone-7f3a91c2", "to": "+919876543210", "webhookUrl": "https://example.com/gateway/events", "metadata": { "orderId": "A-1009" } }' ``` ```json { "call": { "id": "8c1d…", "state": "queued", "direction": "outgoing", "remoteNumber": "+919876543210", … }, "command": { "commandId": "…", "action": "dial" }, "media": { "path": "/v1/calls/8c1d…/media", "format": { "encoding": "pcm_s16le", "sampleRate": 16000, "channels": 1, "frameMs": 20 } } } ``` * `to` is dialled as written. Use the full international format. * `webhookUrl` gets this call’s events instead of the default webhook. * `metadata` is yours; it comes back on reads and webhooks. * `stream` connects an AI agent: see [Connect an AI agent](../agent-streams/). * `409 DEVICE_OFFLINE` means the phone isn’t connected right now. ## Control a live call [Section titled “Control a live call”](#control-a-live-call) ```bash curl -X POST $OPENLINE_URL/v1/calls/$CALL_ID/hangup -H "Authorization: Bearer $OPENLINE_KEY" curl -X POST $OPENLINE_URL/v1/calls/$CALL_ID/hold -H "Authorization: Bearer $OPENLINE_KEY" curl -X POST $OPENLINE_URL/v1/calls/$CALL_ID/unhold -H "Authorization: Bearer $OPENLINE_KEY" curl -X POST $OPENLINE_URL/v1/calls/$CALL_ID/dtmf -H "Authorization: Bearer $OPENLINE_KEY" \ -H "content-type: application/json" -d '{"digits":"1#"}' ``` For ringing incoming calls: `answer` and `reject`. Each returns `202` once the phone has the command. Wait for the event (`call.disconnected`, `call.holding`, …) to know it happened. ## Read calls [Section titled “Read calls”](#read-calls) ```bash # newest first; filter by state, direction or phone curl "$OPENLINE_URL/v1/calls?state=ended&direction=incoming&limit=20" -H "Authorization: Bearer $OPENLINE_KEY" # next page curl "$OPENLINE_URL/v1/calls?limit=20&before=" -H "Authorization: Bearer $OPENLINE_KEY" # one call, its events, its transcript curl $OPENLINE_URL/v1/calls/$CALL_ID -H "Authorization: Bearer $OPENLINE_KEY" curl $OPENLINE_URL/v1/calls/$CALL_ID/events -H "Authorization: Bearer $OPENLINE_KEY" curl $OPENLINE_URL/v1/calls/$CALL_ID/transcript -H "Authorization: Bearer $OPENLINE_KEY" ``` Calls carry `answeredAt` and `endedAt`, so talk time is `endedAt - answeredAt`. ## Pick a phone [Section titled “Pick a phone”](#pick-a-phone) `GET /v1/devices` lists phones with `connected` and `health`. Use one that’s connected; a phone handles one call at a time. # Handle incoming calls > Three ways to deal with calls to your phone's number. When someone calls the phone’s number, the gateway creates a call with `direction: "incoming"` and `state: "ringing"`, and sends a `call.ringing` webhook. Then one of three things handles it. ## 1. Stream every call to your agent [Section titled “1. Stream every call to your agent”](#1-stream-every-call-to-your-agent) Set **Settings → Incoming calls → Stream to your agent** (or `incomingStreamUrl` via `PUT /v1/settings`). Every call is answered and connected to your agent’s WebSocket. [Details](../agent-streams/#incoming-calls). ## 2. Decide in your code [Section titled “2. Decide in your code”](#2-decide-in-your-code) Leave the stream URL empty and handle the `call.ringing` webhook: ```js app.post("/gateway/events", async (req, res) => { res.sendStatus(200); // reply fast; act afterwards const { event, call } = req.body; if (event !== "call.ringing" || call.direction !== "incoming") return; if (isBlocked(call.remoteNumber)) { await openline(`/v1/calls/${call.id}/reject`); return; } // answer and hand it to an agent in one step await openline(`/v1/calls/${call.id}/stream`, { url: "wss://agent.example.com/ws", format: "raw" }); }); const openline = (path, body) => fetch(process.env.OPENLINE_URL + path, { method: "POST", headers: { authorization: `Bearer ${process.env.OPENLINE_KEY}`, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); ``` `POST /v1/calls/{id}/stream` on a ringing call answers it and connects your agent once it’s active. To answer without an agent, `POST /v1/calls/{id}/answer` and join the audio yourself ([media WebSocket](../media/)). ## 3. The built-in assistant [Section titled “3. The built-in assistant”](#3-the-built-in-assistant) Turn on **Answer incoming calls for me** in the Playground. A Gemini voice assistant answers, asks who’s calling and why, and leaves you a message. [Built-in AI assistant](../assistant/). ## Which wins [Section titled “Which wins”](#which-wins) If an incoming stream URL is set, it handles the call and the assistant stays out. Otherwise the assistant answers if auto-answer is on. Otherwise the call keeps ringing until your code (or the caller) does something. # Join call audio yourself > Connect your own code to a call's audio over the media WebSocket. Instead of the gateway connecting to your agent ([streams](../agent-streams/)), your code can connect to the gateway and exchange the call’s audio directly. Useful for scripts, testing, and agents that can’t accept incoming connections. ## Connect [Section titled “Connect”](#connect) ```text wss:///v1/calls/{callId}/media Authorization: Bearer (or ?apiKey= where headers aren't possible) ``` You can connect before the call is answered. Audio starts when the phone starts streaming: ```json {"type":"start","callId":"8c1d…","format":{"encoding":"pcm_s16le","sampleRate":16000,"channels":1,"frameMs":20}} ``` ## Audio [Section titled “Audio”](#audio) * **From the gateway:** binary frames of the other person’s voice, 16 kHz mono 16-bit little-endian PCM, 20 ms (640 bytes) each. * **To the gateway:** binary PCM in the same format. Any length works; it’s played in order. * **Interrupt:** send `{"type":"clear"}` to drop audio that hasn’t played yet. * **End:** `{"type":"stop","callId":"…","reason":"device_media_closed"}` when the phone stops streaming. Hang up with `POST /v1/calls/{id}/hangup`. Only one talking client per call; a new one replaces the old. ```python import asyncio, json, websockets async def main(call_id): url = f"wss://your-openline/v1/calls/{call_id}/media" async with websockets.connect(url, additional_headers={"Authorization": "Bearer gw_..."}) as ws: async for msg in ws: if isinstance(msg, bytes): await ws.send(msg) # echo the caller back to them else: print(json.loads(msg)) asyncio.run(main("8c1d...")) ``` ## Listen only [Section titled “Listen only”](#listen-only) Add `?mode=listen` to hear both sides without being able to talk (as many listeners as you like). Each binary frame starts with one byte saying whose audio it is: | First byte | Audio | | ---------- | ------------------------------------------------ | | `0x01` | The other person on the call | | `0x02` | What your agent or code is sending into the call | The rest of the frame is 16 kHz PCM. Listeners also get `start`, `stop` and `clear` messages. The console’s **Listen in** button uses this. # Set up the phone > Install the OpenLine app, pair it, enable call audio and keep it online. The phone is the line. It needs Android 11 or newer, a SIM that can make calls, and the OpenLine app. Use a spare phone if you can: the app becomes its default phone app. ## Setup steps in the app [Section titled “Setup steps in the app”](#setup-steps-in-the-app) 1. **Default phone app.** Lets the app place, answer and end calls. 2. **Connect to your gateway.** Scan the QR code from the console’s **Connection** page. The app exchanges the one-time code for its own credentials and connects to `wss:///v1/device-channel`. 3. **Call audio.** A one-time pairing with Android’s Wireless debugging. The app uses it to start a small helper with the permissions needed to capture and play call audio, so no root or PC is needed. Tap **Set up**, turn on **Wireless debugging**, tap **Pair device with pairing code**, and type the 6-digit code into the app’s notification. 4. **Stay online in the background.** Allow the app to ignore battery optimisation. On Vivo, also set **Battery → Background power consumption → Allow** for OpenLine. ## Wi-Fi and mobile data [Section titled “Wi-Fi and mobile data”](#wi-fi-and-mobile-data) Android only runs Wireless debugging while the phone is **connected to a Wi-Fi network**. The call-audio helper is started through it, so: * The first start needs Wi-Fi (any network, including another phone’s hotspot). The app shows a **Connect to a Wi-Fi network** prompt when it needs it. * On that first start the app also switches Android’s debugging to a mode that works on any network. After that, calls and call audio work on mobile data until the phone restarts. * After a reboot, connect to Wi-Fi once more. The connection to the server itself works on any network, and the app reconnects by itself when the phone switches between Wi-Fi and mobile data. ## Silent by design [Section titled “Silent by design”](#silent-by-design) During calls the phone puts the call in “audio processing” mode: the caller hears your agent, your agent hears the caller, and nothing plays through the phone’s speaker or earpiece. ## Health [Section titled “Health”](#health) The console’s **Connection** page shows, for each phone: online state, response time, network, signal and carrier, battery, and whether call audio is ready. The same data is in `GET /v1/devices` under `health` and `latencyMs`. ## Vendor notes [Section titled “Vendor notes”](#vendor-notes) Call audio depends on Android behaviour that differs between manufacturers. It’s tested on a Vivo V40 with Android 16. Other phones may need tweaks; check [Troubleshooting](../../self-hosting/troubleshooting/). # Webhooks > Receive OpenLine call events as signed webhooks: payload format, HMAC signature verification in Node.js and Python, retries and the delivery log. The gateway POSTs every call event to a webhook: the call’s own `webhookUrl` if it has one, otherwise the default from **Settings → Server → Default webhook** (or `WEBHOOK_URL` in `.env`). ## Payload [Section titled “Payload”](#payload) ```json { "event": "call.active", "eventId": "evt_5f0c…", "occurredAt": "2026-10-10T09:41:07.120Z", "device": { "id": "phone-7f3a91c2", "name": "Vivo V2348", "connected": true, … }, "call": { "id": "8c1d…", "direction": "outgoing", "remoteNumber": "+919876543210", "state": "active", "answeredAt": "2026-10-10T09:41:07.300Z", "metadata": { "orderId": "A-1009" }, … }, "payload": { "direction": "outgoing", "number": "+919876543210", "state": "active" } } ``` `call` is the call **after** the event. `occurredAt` is the phone’s clock; use the call’s timestamps (server clock) for ordering. [All event names](../../reference/call-states/). ## Verify the signature [Section titled “Verify the signature”](#verify-the-signature) Each request has two headers: * `x-openline-timestamp`: Unix seconds. * `x-openline-signature`: `sha256=` + hex HMAC-SHA256 of `"."`. The secret is `WEBHOOK_SECRET` from `server/.env`, or `HTTP_GATEWAY_API_KEY` if that isn’t set. ```js import { createHmac, timingSafeEqual } from "node:crypto"; function verify(rawBody, headers, secret) { const timestamp = headers["x-openline-timestamp"]; const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); const given = String(headers["x-openline-signature"] ?? ""); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; return fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)); } ``` ```python import hmac, hashlib, time def verify(raw_body: bytes, headers, secret: str) -> bool: ts = headers["x-openline-timestamp"] expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest() fresh = abs(time.time() - int(ts)) < 300 return fresh and hmac.compare_digest(expected, headers.get("x-openline-signature", "")) ``` Use the raw request body, before any JSON parsing. ## Retries [Section titled “Retries”](#retries) Reply with any `2xx` within 5 seconds. Otherwise the gateway retries after 5 seconds, 30 seconds and 2 minutes (4 attempts in all). Every attempt is logged: ```bash curl "$OPENLINE_URL/v1/webhook-deliveries?callId=$CALL_ID" -H "Authorization: Bearer $OPENLINE_KEY" ``` Events can arrive more than once and, with retries, out of order. Use `eventId` to de-duplicate and the call’s `state` as the truth. # Call states and events > Every call state and every event the phone reports. ## States [Section titled “States”](#states) | State | Meaning | | ---------- | ------------------------------------------------------------------ | | `queued` | Created; the phone is starting the call. | | `dialing` | Outgoing call is connecting. | | `ringing` | Incoming call is ringing on the phone. | | `active` | Connected. Audio flows. | | `held` | On hold. | | `ended` | Finished normally (either side hung up). | | `rejected` | Incoming call declined. | | `failed` | Couldn’t be placed or a command failed (see the event’s `reason`). | `ended`, `rejected` and `failed` are final. A call that has been `active` has `answeredAt`; a finished call has `endedAt`. ```text outgoing: queued → dialing → active ⇄ held → ended ↘ ended / failed (no answer, busy, error) incoming: ringing → active ⇄ held → ended ↘ rejected / ended (declined, caller gave up) ``` ## Events [Section titled “Events”](#events) Sent as webhooks and listed by `GET /v1/calls/{id}/events`. | Event | State after | When | | ------------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------- | | `call.new` | `queued` | The phone created the call. | | `call.connecting`, `call.select_phone_account`, `call.dialing`, `call.pulling` | `dialing` | Outgoing call in progress. | | `call.ringing`, `call.simulated_ringing` | `ringing` | Incoming call ringing. | | `call.active` | `active` | Answered. | | `call.audio_processing` | `active` | Answered and routed to the gateway (phone silent). | | `call.holding` | `held` | Put on hold. | | `call.disconnecting`, `call.disconnected` | `ended` | Call ended. `payload.disconnectCause` has Android’s cause code. | | `call.rejected` | `rejected` | Incoming call declined. | | `call.failed` | `failed` | A command failed; `payload.reason` says why. | The phone also sends `command.ack` and `command.completed` for commands; these don’t change the call and aren’t sent to webhooks. Each event’s `payload` usually carries `number`, `direction` and `state` as the phone sees them. # Errors > Error format and codes. Every error has the same shape: ```json { "error": { "code": "DEVICE_OFFLINE", "message": "device is offline" } } ``` Validation errors add `issues` with the field-level details. | HTTP | Code | Meaning | | ---- | ------------------- | ----------------------------------------------------------------- | | 400 | `VALIDATION_ERROR` | The body or query didn’t validate. See `issues`. | | 400 | `INVALID_JSON` | The body isn’t valid JSON. | | 401 | `UNAUTHORIZED` | Missing or invalid API key. | | 404 | `NOT_FOUND` | No such call, phone, key or route. | | 409 | `DEVICE_OFFLINE` | The phone isn’t connected; it can’t take commands right now. | | 409 | `CALL_NOT_LIVE` | Streams can only be attached to live calls. | | 409 | `AGENT_UNAVAILABLE` | The assistant can’t join (no Gemini key, or the call isn’t live). | | 429 | `TOO_MANY_ATTEMPTS` | Too many wrong pairing codes or passwords; wait a minute. | | 500 | `INTERNAL_ERROR` | A bug. The server log has details. | Retry `DEVICE_OFFLINE` after the phone reconnects (`GET /v1/devices`). Don’t retry `4xx` errors unchanged. # For AI agents (llms.txt) > Machine-readable versions of these docs for coding assistants and AI agents. These docs are also published in formats made for language models, following the [llms.txt](https://llmstxt.org) convention: | File | Contents | | ----------------------------------------- | ------------------------------------------------------ | | [`/llms.txt`](../../llms.txt) | Short overview and an index of the documentation sets. | | [`/llms-full.txt`](../../llms-full.txt) | All documentation in one Markdown file. | | [`/llms-small.txt`](../../llms-small.txt) | A trimmed version for small context windows. | | [`/openapi.yaml`](../../openapi.yaml) | The API as an OpenAPI 3.1 spec. | ## Using them [Section titled “Using them”](#using-them) * **Coding assistants (Claude Code, Cursor, Copilot):** paste the `llms-full.txt` URL, or add it as a documentation source, before asking for an integration. * **Agents that call the API:** give them `openapi.yaml` as their tool definition; most agent frameworks can turn an OpenAPI spec into tools. * **Client libraries:** generate one from `openapi.yaml` with any OpenAPI generator. A good prompt to start with: > Read . Using the OpenLine API, write a FastAPI service that places a call with `stream` pointing at a Pipecat bot, and handles the `call.disconnected` webhook. # Stream protocol > Exact messages exchanged with your agent's WebSocket, in raw and Twilio formats. When a call has a `stream`, the gateway connects to `stream.url` as a WebSocket **client** when the call is answered. These are the messages on that socket. ## Raw format (`"format": "raw"`) [Section titled “Raw format ("format": "raw")”](#raw-format-format-raw) **OpenLine → agent** ```json {"type":"start","callId":"8c1d…","direction":"outgoing","remoteNumber":"+919876543210", "deviceId":"phone-7f3a91c2","customerId":"c_123", "format":{"encoding":"pcm_s16le","sampleRate":16000,"channels":1,"frameMs":20}} ``` Your `parameters` are merged into the start message. Then binary frames: the other person’s voice, 16 kHz mono s16le, 640 bytes (20 ms) each. When the call ends: ```json {"type":"stop","callId":"8c1d…"} ``` **Agent → gateway** * Binary PCM in the same format, any length. Played in order. * `{"type":"clear"}` drops audio that hasn’t played yet. ## Twilio format (`"format": "twilio"`) [Section titled “Twilio format ("format": "twilio")”](#twilio-format-format-twilio) Matches Twilio Media Streams for a bidirectional ``. **OpenLine → agent** ```json {"event":"connected","protocol":"Call","version":"1.0.0"} ``` ```json {"event":"start","sequenceNumber":"1","streamSid":"MZ8c1d…", "start":{"streamSid":"MZ8c1d…","accountSid":"local","callSid":"8c1d…","tracks":["inbound"], "customParameters":{"callId":"8c1d…","direction":"outgoing","remoteNumber":"+919876543210", "deviceId":"phone-7f3a91c2","customerId":"c_123"}, "mediaFormat":{"encoding":"audio/x-mulaw","sampleRate":8000,"channels":1}}} ``` ```json {"event":"media","sequenceNumber":"2","streamSid":"MZ8c1d…", "media":{"track":"inbound","chunk":"1","timestamp":"20","payload":""}} ``` ```json {"event":"mark","sequenceNumber":"9","streamSid":"MZ8c1d…","mark":{"name":"turn-1"}} ``` ```json {"event":"stop","sequenceNumber":"120","streamSid":"MZ8c1d…","stop":{"accountSid":"local","callSid":"8c1d…"}} ``` **Agent → gateway** ```json {"event":"media","streamSid":"MZ8c1d…","media":{"payload":""}} {"event":"mark","streamSid":"MZ8c1d…","mark":{"name":"turn-1"}} {"event":"clear","streamSid":"MZ8c1d…"} ``` * A `mark` is sent back once all audio you sent before it has played. * `clear` drops unplayed audio; pending marks are sent back at once, as on Twilio. * `streamSid` on incoming messages isn’t checked. ## Lifecycle [Section titled “Lifecycle”](#lifecycle) | Situation | What happens | | ---------------------------------- | ------------------------------------------------------------------------ | | Call answered | OpenLine connects, sends `start` (and `connected` first for Twilio). | | Other person hangs up | OpenLine sends `stop` and closes the socket. | | Agent closes the socket | OpenLine hangs up the call. | | Agent unreachable or socket errors | OpenLine hangs up the call; `GET /v1/calls/{id}/stream` shows the error. | | `DELETE /v1/calls/{id}/stream` | OpenLine closes the socket; the call continues. | ## Audio conversion [Section titled “Audio conversion”](#audio-conversion) Twilio format converts on the fly: 16 kHz PCM → 8 kHz mu-law towards your agent (pairs of samples averaged), and 8 kHz mu-law → 16 kHz PCM towards the call (linear interpolation). Use `raw` for the best quality. # Security > What's protected, how, and what you should do. Your gateway can place calls on your SIM, so treat its credentials like a payment card. ## What’s in place [Section titled “What’s in place”](#whats-in-place) * **API keys** are random 32-byte tokens, stored only as SHA-256 hashes, revocable one by one. * **Console accounts** use email and password (scrypt hashes). Sessions are `HttpOnly`, `SameSite=Strict` cookies, `Secure` over https, valid for 30 days. Repeated wrong passwords are throttled. * **First account:** creating it through the public address requires the setup key (`HTTP_GATEWAY_API_KEY`), so a fresh install exposed by a tunnel can’t be claimed by someone else. * **Phones** have their own random token each (hashed on the server), issued through a one-time pairing code valid for 10 minutes, with throttling against guessing. * **Webhooks** are signed with HMAC-SHA256 and a timestamp. * **Secrets in Settings** (such as the Gemini key) never come back from the API in full, only their last four characters. ## What you should do [Section titled “What you should do”](#what-you-should-do) * Use a long random `HTTP_GATEWAY_API_KEY` and keep `server/.env` private. It’s git-ignored. * Create a separate API key per app and revoke keys you no longer use. * Verify webhook signatures and reject old timestamps. * Only stream calls to agents you control. Call audio is personal data, and some places require telling callers they’re talking to an AI or being recorded. * Keep `server/data/gateway.db` private and backed up. It has call logs and transcripts. ## On the phone [Section titled “On the phone”](#on-the-phone) The call-audio helper runs with Android’s `shell` permissions, started through Wireless debugging with a key only this app holds. After the first start, Android’s debugging also listens on TCP port 5555 until the phone restarts. Only devices you’ve approved can use it, but avoid running the phone on untrusted Wi-Fi networks. # Run the server > Install, configure and run the OpenLine server, and where its data lives. The server is a Node.js app in `server/`. It needs **Node.js 22.13+** (it uses the built-in `node:sqlite`) and nothing else: no database server, no Redis. ## Install and start [Section titled “Install and start”](#install-and-start) ```bash cd server cp .env.example .env # then set HTTP_GATEWAY_API_KEY npm install npm run build npm start # http://localhost:8787 ``` On Windows, `run-server.bat` in the repo root creates `.env` on first run, installs, builds, opens Tailscale Funnel on port 8787 and starts the server. `run-server.bat nofunnel` skips the Funnel. ## Configuration [Section titled “Configuration”](#configuration) `server/.env` holds the few things needed to boot. Everything else is set in the console (**Settings**) and stored in the database; `.env` values are only defaults. | Variable | Needed | What it does | | ------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------- | | `HTTP_GATEWAY_API_KEY` | yes | Root API key. Also the setup key when the first console account is created through a public address. | | `PORT`, `HOST` | no | Listen address. Default `8787` on `0.0.0.0`. | | `DATA_DIR` | no | Folder for `gateway.db`. Default `data`. | | `PUBLIC_URL` | no | Public `https://` address. Settings → Server. | | `WEBHOOK_URL` | no | Default webhook. Settings → Server. | | `WEBHOOK_SECRET` | no | Webhook signing secret. Defaults to the root API key. | | `GEMINI_API_KEY`, `GEMINI_LIVE_MODEL` | no | Built-in assistant. Settings → AI assistant. | | `AGENT_OWNER_NAME` | no | Default name the assistant answers for. | | `DOCS_URL` | no | Where the console’s Docs link points. | ## The console [Section titled “The console”](#the-console) `http://localhost:8787/` serves the console: * **Home:** getting started and API quickstart. * **Playground:** test calls by hand, talk or listen from the laptop, try the assistant. * **Connection:** pairing QR code and live phone health. * **Settings:** keys, addresses, incoming-call routing, accounts. The first visit creates the owner account (email and password). On the server’s own machine that’s all; through the public address it also asks for the setup key, so nobody else can claim a fresh install. More people can be added under **Settings → People with access**. ## Data [Section titled “Data”](#data) Everything is in one SQLite file, `server/data/gateway.db`: phones, calls, call events, transcripts, assistant messages, webhook deliveries, settings, API keys (hashed), accounts (hashed passwords) and sessions. Back it up by copying the file while the server is stopped. ## Keep it running [Section titled “Keep it running”](#keep-it-running) The server is a single process. Run it under whatever keeps processes alive on your machine: a terminal you leave open, `pm2`, a systemd unit, or Windows Task Scheduler at log-on. Phones reconnect on their own when it comes back. # Troubleshooting > Fixes for the problems you're most likely to hit. ## The phone shows offline [Section titled “The phone shows offline”](#the-phone-shows-offline) * **Public address down.** Open `https:///health` from your phone’s browser. It should say `{"ok":true}`. If not, check the tunnel (`tailscale funnel status`) and that the server is running. * **App frozen in the background.** Allow step 4 in the app (**Stay online in the background**), and on Vivo also **Battery → Background power consumption → Allow**. The app keeps a small “Connected to your gateway” notification; if it’s gone, open the app. * **Address changed.** Phones keep the address they were paired with. Pair again. The Connection page shows the last health report, even when the phone is offline. ## “Connect to a Wi-Fi network” keeps appearing [Section titled ““Connect to a Wi-Fi network” keeps appearing”](#connect-to-a-wi-fi-network-keeps-appearing) Android only runs Wireless debugging while the phone is connected to a Wi-Fi network. Having Wi-Fi switched on isn’t enough, and the phone’s own hotspot doesn’t count. Join any network for a moment, including another phone’s hotspot. After that first start, call audio works on mobile data until the next reboot. ## Calls connect but there’s no audio [Section titled “Calls connect but there’s no audio”](#calls-connect-but-theres-no-audio) * The app’s **Call audio** step should say **Ready**. If it says **Stopped**, open the app on Wi-Fi. * If pairing was lost (e.g. after clearing app data), tap **Set up** again. * `GET /v1/calls/{id}` shows `media.streaming: true` while the phone is sending audio. ## Audio plays on the phone’s speaker [Section titled “Audio plays on the phone’s speaker”](#audio-plays-on-the-phones-speaker) The app puts calls in “audio processing” mode as soon as they connect, which keeps the handset silent. If you hear audio on the phone, make sure OpenLine is the default phone app (step 1). ## My agent never gets a connection [Section titled “My agent never gets a connection”](#my-agent-never-gets-a-connection) * The URL must be `ws://` or `wss://`, reachable **from the server**. * `GET /v1/calls/{id}/stream` shows the status and, on failure, the error. * The stream only starts when the call is answered. ## Webhooks don’t arrive [Section titled “Webhooks don’t arrive”](#webhooks-dont-arrive) `GET /v1/webhook-deliveries` lists every attempt with the HTTP status or error. ## Forgot the console password [Section titled “Forgot the console password”](#forgot-the-console-password) Another account holder can remove and re-add you under **Settings → People with access**. If you’re the only account, stop the server and delete the `users` and `sessions` rows from `server/data/gateway.db` (any SQLite tool), then create the account again. # Public address > Give the server an https address with Tailscale Funnel or Cloudflare Tunnel. The phone connects to the server over the internet, so the server needs a public `https://` address. Tunnels give you one without opening router ports. After setting one up, save the address in **Settings → Server → Public address**: the pairing QR code and the docs examples use it. ## Tailscale Funnel [Section titled “Tailscale Funnel”](#tailscale-funnel) Free, stable address, no domain needed. ```bash tailscale funnel --bg 8787 # the first time, it asks you to enable Funnel for your tailnet tailscale funnel status # https://..ts.net ``` The address stays the same as long as the machine name does. `--bg` keeps it on after you close the terminal and across restarts. ## Cloudflare Tunnel [Section titled “Cloudflare Tunnel”](#cloudflare-tunnel) Use your own domain, or a throwaway address for testing. ```bash # quick test: random address that changes every time cloudflared tunnel --url http://localhost:8787 # permanent, on your domain cloudflared tunnel login cloudflared tunnel create gateway cloudflared tunnel route dns gateway gateway.example.com cloudflared tunnel run --url http://localhost:8787 gateway ``` ## Changing the address [Section titled “Changing the address”](#changing-the-address) Phones remember the address they were paired with. If it changes, update **Public address** and pair the phone again (scan a new QR code). ## Agents on other machines [Section titled “Agents on other machines”](#agents-on-other-machines) When the gateway [connects to your agent](../../guides/agent-streams/), it’s the server that opens the connection, so your agent needs an address the **server** can reach. On the same machine, `ws://127.0.0.1:` is enough; elsewhere, give the agent its own tunnel or public host.