Voice frame protocol
What goes over the wire between a web page and a Leadlock voice agent. The hosted script speaks it for you, so most sites never touch it. Read this to debug a site or build your own client.
The voice is speech-to-speech end to end: the page streams microphone audio up and plays the agent's audio
back. Every frame is a JSON text message with a type field. Both sides ignore a type they do not
know, so /v1/ only ever changes additively. A breaking change would ship as /v2/.
URL and query
| Surface | URL |
|---|---|
| Demo page, website script (default) | wss://leadlock-app.onrender.com/d/{slug}/voice |
| Share link | wss://leadlock-app.onrender.com/share/{slug}/voice |
{slug}is the demo or share slug. It is the only credential, so it is public by design; the caps and the Origin lock below are what protect it.- Optional query
?host_tools=<base64url JSON array>: the page's own tools, each{"name", "description", "parameters"}(see Host tools). The script adds it only when the page registered a tool. - The script tag attribute
data-leadlock-ws(or thewsoption ofLeadlockVoice.create) overrides the basewss://leadlock-app.onrender.com, for example to point at a local test server.data-leadlock-surface="share"switches to the share socket.
The script opens the socket when the visitor clicks, then at once sends context and talk_start.
Script tag
<script src="https://app.leadlock.ai/v1/leadlock-voice.js" data-leadlock-slug="YOUR_DEMO_SLUG"></script>
The MCP tool get_voice_doors returns this exact tag for an agent's demo. The script also reads the
demo's saved look with one GET https://leadlock-app.onrender.com/d/{slug}/look request; that is
the only other request it makes. Script source:
app.leadlock.ai/v1/leadlock-voice.js.
Audio format
- Both directions: base64 of raw PCM16 (signed 16-bit little-endian), mono.
- Browser to server: 24 kHz. The script resamples the microphone to 24 kHz before sending.
- Server to browser: play at the
sampleRatefromsession.ready(24000 on most voice models, 16000 on some). Untilsession.readyarrives, assume 24000.
Client to server frames
| Type | Fields | Meaning |
|---|---|---|
context | variables (object of strings) | Page variables for the agent, sent before talk_start. The script sends current_page, and for a returning visitor visitor_name, returning_visitor and last_interest. |
talk_start | none | The visitor wants to talk: the session is admitted and the call starts. Until this arrives the socket is warm and nothing is billed. A first audio frame also counts as talk_start (that one frame is dropped). |
audio | audio (base64 PCM16, 24 kHz mono) | Microphone audio, streamed continuously. |
stop | none | End the call. Before talk_start it abandons the warm socket. |
host_tool_result | call_id, result (any JSON) | The page's answer to a host tool function_call. Echo the call_id from that frame. An unknown or already answered call_id is ignored. |
Server to client frames
| Type | Fields | Meaning |
|---|---|---|
session.ready | agent (agent name), sampleRate (number) | The voice session is set up. Use sampleRate for playback. |
audio | audio (base64 PCM16 mono) | Agent audio. Queue chunks back to back for gapless playback. |
transcript | role (user or assistant), delta or text, timestamp | Caption text: delta is a partial piece, text a finished line. The script emits it as its transcript event. |
transcript_flush | role, optional timestamp | The current caption line for that role is finished. |
interrupt | message | The visitor talked over the agent: stop and clear any queued agent audio now. |
function_call | function, arguments, timestamp; host tools add call_id and tool_kind: "host"; MCP tools carry tool_kind: "mcp" | The agent called a tool. Page actions (web_*) and host tools run in the page; MCP and server tools are only reported. |
function_result | function, result, success, timestamp; host and MCP tools add tool_kind and call_id | The outcome of a tool call. Informational; the script ignores it. |
session_end | reason, optional message, optional duration | The call ended on the server side. Reasons include ai_requested (the agent hung up), time_limit (with duration in seconds), silence_timeout and replaced. End quietly. |
error | message or error (text), optional code | Something stopped the call. Show message when present. Codes include busy (always followed by close 1013), TRIAL_MINUTE_CAP_REACHED, CALL_VETOED_SUB_ACCOUNT_PAUSED and CALL_VETOED_SUB_ACCOUNT_INACTIVE. |
Close codes
| Code | When | What the script does |
|---|---|---|
1013 | Busy: the demo, the account or this visitor's address already has its full number of live calls. Sent right after an error frame with code: "busy". | Shows "This demo is busy right now. Please try again in a minute." |
4001 | Quiet eviction of a warm socket (reason evicted) to make room for a newer one. No error frame. | Goes back to idle with no message. The next click reconnects. |
1008 | The page's Origin is not on the demo's allowlist. The server closes before accepting, so the browser never sees 1008: it sees the handshake fail with HTTP 403 and the socket closes without opening. | Shows "Voice chat is not available on this page right now." The same message covers an unreachable server. |
1000 | Normal close. | Ends quietly. |
1001 | Going away (page or server shutting down). | Ends quietly. |
1005 | Closed with no status code. | Ends quietly. |
Any other close after the socket opened means the connection was lost: the script shows "Could not connect. Please try again."
Origin rules
- Demo slugs can carry an optional allowlist of origins, set in the demo's config as
allowed_origins. Share links have no Origin lock. https://app.leadlock.aiis always allowed, because the hosted demo page and the iframe embed connect from it.- Origins are compared after normalizing: lowercase, path and trailing slash dropped, default port dropped.
HTTPS://Example.com/matcheshttps://example.com. List each subdomain you serve from, sincewww.is a different origin. - An entry with no scheme (
example.com) is read ashttps://example.com. Entries that are still not an origin are dropped. If no entry is valid, the list counts as empty. - An empty or missing list means no Origin check. A lookup error, or a lookup slower than 2 seconds, also skips the check; the caps still apply.
- With a non-empty list, a handshake with no Origin header is refused. Browsers always send one.
- An allowlist change takes effect within 60 seconds.
Warm sockets and caps
A socket is warm from the handshake until talk_start, and live after it. Only live sockets are billed.
- A warm socket may stay open for at most 60 seconds, then it is closed.
- Live calls: at most 5 at once per slug, 10 per account by default, and 3 per visitor address on one slug. A
talk_startpast any of these gets the busy refusal (errorwithcode: "busy", then close1013). - Warm sockets are never refused. When too many are open (10 per visitor address, 20 per slug, 100 across the service), the oldest warm socket in that group is closed with
4001and the new one gets in. A live call is never evicted.
Host tools
A page can give the agent its own actions with LeadlockVoice.registerTool(name, schema, handler).
The model waits for the handler's real result, unlike the 8 built-in page actions (web_navigate,
web_scroll, web_scroll_to, web_highlight, web_clear_highlight,
web_fill, web_click, web_remember), which are fire and forget.
Tools are declared in the socket URL as ?host_tools= because the voice session is configured
before the first frame is read.
Host tools work on agents that run on the Grok, OpenAI Realtime or Gemini Live voice models; other voice models never receive them, with no error. A host tool whose name matches one of the agent's own tools or MCP server labels is dropped.
| Limit | Value |
|---|---|
Raw host_tools query value | 8192 characters, checked before decoding |
| Tools per page | 16 |
| Tool name | ^[A-Za-z][A-Za-z0-9_]{0,63}$ |
| Reserved name prefixes | web_, x_, file_search, collections_search |
| Description | 1024 characters |
parameters | JSON schema with "type": "object" |
| All declarations together | 6144 characters of compact JSON |
| Wait for a result | 10 seconds, then the model gets {"success": false, "error": "timeout"} |
| One result | 8192 characters of compact JSON, else {"success": false, "error": "result_too_large"} |
A bad declaration is dropped, never fatal: a broken host_tools value just means no host tools.
Round trip
- The agent calls a host tool. The server sends
function_callwith a freshcall_idandtool_kind: "host". - The page runs the handler and sends
{"type": "host_tool_result", "call_id": "...", "result": ...}. A handler that throws sends{"success": false, "error": "<message>"}as the result. - The server passes the result to the agent and sends
function_resultwith the samecall_id.
The handler runs in the visitor's own browser, so a host tool can do nothing the page could not already do.
For agents
The same reference is in Markdown at the end of llms-full.txt, and llms.txt lists every agent-readable page.