Skip to content
TriedDocs
Custom LLM

WebSocket server protocol

Every frame Tried sends to your custom LLM server and every frame your server can send back, with JSON examples and timing rules.

This page is the full reference for the custom LLM WebSocket protocol as implemented by the Tried voice worker (services/agent, custom_llm.py). The frame shapes are defined in packages/shared as CustomLlmInbound (Tried to your server) and CustomLlmOutbound (your server to Tried). They match Retell's custom LLM protocol.

Connection#

For every call, the worker opens one WebSocket client connection to:

{llm_websocket_url}/{call_id}

A trailing slash on llm_websocket_url is removed before the call id is appended. For wss://llm.example.com/llm-websocket and call call_8f2c..., the worker connects to wss://llm.example.com/llm-websocket/call_8f2c.... Route on the last path segment to get the call id.

  • Every frame is a JSON text frame. Binary frames are ignored.
  • Tried frames carry interaction_type. Your frames carry response_type.
  • The connection is opened before the agent joins the call. If it cannot be opened, the call ends with error_llm_websocket_open.
  • Plain WebSocket control pings are answered automatically. The application-level keepalive is the ping_pong frame below.

Sequence of a typical call#

worker  -> connects to {url}/{call_id}
server  -> config { auto_reconnect: true, call_details: true }
worker  -> call_details { call: {...} }
server  -> response { response_id: 0, content: "Hi, this is Ava...", content_complete: true }
worker  -> ping_pong (every 2 s)          server -> ping_pong (echo)
worker  -> update_only { transcript, turntaking: "user_turn" }
worker  -> update_only { transcript, turntaking: "agent_turn" }
worker  -> response_required { response_id: 1, transcript }
server  -> response { response_id: 1, content: "Sure", content_complete: false }
server  -> response { response_id: 1, content: ", which day?", content_complete: true }
...

Frames Tried sends#

ping_pong#

Sent every 2 seconds, only after your server has turned on auto_reconnect in a config frame.

{ "interaction_type": "ping_pong", "timestamp": 1760851200123 }
Field Type Description
timestamp number Milliseconds since the Unix epoch when the ping was sent.

Reply with your own ping_pong that echoes the same timestamp. The worker measures the round trip from it (see Latency).

call_details#

Sent once right after the connection opens, only if your config frame (received within 0.5 seconds of connecting) set call_details: true. It is sent again after a reconnect.

{
  "interaction_type": "call_details",
  "call": {
    "call_id": "call_8f2c4b1e9d0a7c6b5e4f3a21",
    "call_type": "phone_call",
    "agent_id": "agent_46117b4b00fe0358e861a6d3",
    "direction": "inbound",
    "from_number": "+61411111111",
    "to_number": "+61355505030",
    "metadata": {},
    "retell_llm_dynamic_variables": { "first_name": "Sam" },
    "collected_dynamic_variables": {},
    "transcript": ""
  }
}
Field Type Description
call_id string The call id, the same as the last path segment.
call_type "phone_call" or "web_call"
agent_id string
direction "inbound", "outbound" or null Null when the call has no direction, as on web calls.
from_number, to_number string or null E.164 numbers on phone calls.
metadata object The call's metadata.
retell_llm_dynamic_variables object Default dynamic variables merged with the values passed when the call was created.
collected_dynamic_variables object Variables collected so far in the call.
transcript string The transcript so far as text (empty at the start).

update_only#

Sent every time an utterance becomes final, for both the caller and the agent. It never needs a reply.

{
  "interaction_type": "update_only",
  "transcript": [
    { "role": "agent", "content": "Hi, this is Ava from Acme. How can I help?" },
    { "role": "user", "content": "I need to move my appointment." }
  ],
  "turntaking": "agent_turn"
}
Field Type Description
transcript array of { role, content } The full transcript so far. role is agent or user.
turntaking "agent_turn" or "user_turn" agent_turn after a user utterance, user_turn after an agent utterance.

Updates are sent without waiting, so they can arrive just before or just after a response_required for the same turn. Treat the transcript in response_required as the source of truth for a reply.

response_required#

Sent when the caller has finished a turn and the agent must reply.

{
  "interaction_type": "response_required",
  "response_id": 3,
  "transcript": [
    { "role": "agent", "content": "Hi, this is Ava from Acme. How can I help?" },
    { "role": "user", "content": "I need to move my appointment to Thursday." }
  ]
}
Field Type Description
response_id integer Starts at 1 and increases by 1 for every request (response or reminder) in the call.
transcript array of { role, content } The full transcript at the time of the request.

reminder_required#

Sent when the caller has been silent for the agent's reminder_trigger_ms (default 10000) and fewer than reminder_max_count (default 1) reminders have been sent. The shape is the same as response_required.

{
  "interaction_type": "reminder_required",
  "response_id": 4,
  "transcript": [
    { "role": "agent", "content": "Which day suits you?" }
  ]
}

Reply with a short nudge ("Are you still there?") using the same response_id. The reply is spoken and its end_call, transfer_number and digit_to_press flags are applied.

response_required versus reminder_required#

response_required reminder_required
Trigger The caller finished speaking The caller was silent for reminder_trigger_ms
Typical reply The next step of the conversation A short check-in
Counts toward reminder_max_count No Yes
Uses the shared response_id counter Yes Yes

Supersession by response_id#

Only the newest request is live. When a new response_required or reminder_required goes out, any response frame that still carries an older response_id is dropped. This happens when the caller interrupts or keeps talking while your server is still generating. Stop generating for an old id as soon as a newer one arrives.

Frames your server sends#

config#

Optional. Send it as the first frame after the connection opens. The worker waits up to 0.5 seconds for it before it decides whether to send call_details and start pings.

{
  "response_type": "config",
  "config": {
    "auto_reconnect": true,
    "call_details": true,
    "transcript_with_tool_calls": false
  }
}
Field Type Default Description
auto_reconnect boolean false Turns on ping_pong every 2 seconds and reconnects when no ping reply arrives for 5 seconds or the socket closes.
call_details boolean false Sends a call_details frame after connecting.
transcript_with_tool_calls boolean false Accepted for Retell compatibility. The current worker always sends transcript only and does not add a transcript_with_tool_calls field.
Note

A config frame that arrives after the 0.5 second window is still stored, but call_details is only sent at connect time and pings only start at connect time. Send config immediately on open.

ping_pong#

Reply to each ping_pong from Tried, echoing its timestamp.

{ "response_type": "ping_pong", "timestamp": 1760851200123 }

Every reply resets the 5 second reconnect timer.

response#

The words the agent speaks for a request, streamed as one or more frames with the same response_id.

{ "response_type": "response", "response_id": 3, "content": "No problem.", "content_complete": false }
{ "response_type": "response", "response_id": 3, "content": " Thursday at 2:30 is free.", "content_complete": true }
Field Type Required Description
response_id integer Yes The id of the request you are answering. Use 0 only for the opening line (below).
content string Yes The next chunk of text. Chunks are concatenated. An empty string is allowed.
content_complete boolean Yes true on the last frame of the response.
end_call boolean No Hang up after the response is spoken. Disconnection reason agent_hangup.
transfer_number string No Cold transfer the caller to this number after the response (SIP INVITE).
show_transferee_as_caller boolean No With transfer_number, show the caller's number to the transfer target.
digit_to_press string No DTMF digits to send after the response is spoken, for navigating an IVR.
no_interruption_allowed boolean No The caller cannot interrupt this speech. Applied to the opening line, agent_interrupt and reminder replies.

The worker starts speaking as soon as the first chunk arrives. Action flags can be set on any frame of the response; they are applied after the last frame. If both are set, transfer_number wins over end_call. digit_to_press is sent first, after the agent finishes speaking.

{
  "response_type": "response",
  "response_id": 5,
  "content": "I'll put you through to our billing team now.",
  "content_complete": true,
  "transfer_number": "+61290000000",
  "show_transferee_as_caller": true
}
{ "response_type": "response", "response_id": 2, "content": "", "content_complete": true, "digit_to_press": "2" }

The opening line (response_id 0)#

To make the agent speak first, send a response with response_id: 0 right after the connection opens, before the first response_required. The chunks are buffered until content_complete: true, then spoken. Its action flags are applied too.

{ "response_type": "response", "response_id": 0, "content": "Hi, this is Ava from Acme.", "content_complete": false }
{ "response_type": "response", "response_id": 0, "content": " How can I help?", "content_complete": true }

If you send nothing, the agent waits for the caller to speak. A response_id: 0 frame that arrives after the first request is ignored.

agent_interrupt#

Makes the agent speak without a request, for example when a long lookup finishes or a supervisor pushes a message. The text is buffered per interrupt_id until content_complete: true, then spoken.

{
  "response_type": "agent_interrupt",
  "interrupt_id": 1,
  "content": "Sorry for the wait, I found your booking.",
  "content_complete": true,
  "no_interruption_allowed": false
}
Field Type Required Description
interrupt_id integer Yes Groups the chunks of one interrupt. Use a new id per interrupt.
content string Yes Text chunk.
content_complete boolean Yes true on the last chunk.
no_interruption_allowed boolean No The caller cannot barge in.
end_call boolean No Hang up after speaking.
transfer_number string No Cold transfer after speaking.
digit_to_press string No Send DTMF after speaking.

update_agent#

Changes turn-taking settings for the rest of the call.

{
  "response_type": "update_agent",
  "agent_config": {
    "responsiveness": 0.4,
    "interruption_sensitivity": 0.7,
    "reminder_trigger_ms": 15000,
    "reminder_max_count": 2
  }
}
Field Type Description
responsiveness number 0 to 1 Applied to the live end-of-turn detector immediately. Lower waits longer before replying.
interruption_sensitivity number 0 to 1 Stored on the call's agent settings.
reminder_trigger_ms number Stored on the call's agent settings.
reminder_max_count number Stored on the call's agent settings.
Only responsiveness changes live behaviour today

In the current worker the silence and reminder timer, and the interruption settings, are read when the call starts. update_agent writes the new values, but only responsiveness takes effect during the call.

tool_call_invocation and tool_call_result#

Record tool calls that your server ran, so they appear in the call transcript. See Function calling.

{ "response_type": "tool_call_invocation", "tool_call_id": "t1", "name": "lookup_booking", "arguments": "{\"phone\":\"+61411111111\"}" }
{ "response_type": "tool_call_result", "tool_call_id": "t1", "content": "{\"booking\":\"Tue 14:00\"}" }
Frame Fields
tool_call_invocation tool_call_id (string), name (string), arguments (string, JSON encoded)
tool_call_result tool_call_id (string), content (string)

metadata#

{ "response_type": "metadata", "metadata": { "crm_lead_id": "L-1042" } }

Merged into the call's metadata (top-level keys, later frames win), so it appears on the call object, in webhooks and in Call History. The merged object is capped at 50,000 characters of JSON; a frame that would pass the cap is ignored.

Reconnects#

With auto_reconnect: true:

  1. The worker sends ping_pong every 2 seconds.
  2. If no ping_pong reply has arrived for 5 seconds, or the socket closes, the worker closes the socket and connects again to the same URL.
  3. After a reconnect it waits for your config again and resends call_details if asked.
  4. If a request was in flight, it resends response_required with the same response_id and the last transcript.
  5. After 2 reconnects in one call, the next failure ends the call with error_llm_websocket_lost_connection.

Without auto_reconnect, a socket closed by your server ends the call with error_llm_websocket_lost_connection.

Errors and disconnection reasons#

Reason When
error_llm_websocket_open The first connection could not be opened, or the agent has no llm_websocket_url.
error_llm_websocket_lost_connection The socket closed without auto_reconnect, a reconnect failed, or 2 reconnects were already used.
error_llm_websocket_corrupt_payload A frame was not valid JSON, had an unknown response_type, or lacked a required field such as response_id.
error_llm_websocket_runtime The reader failed unexpectedly, or no frame of a requested response arrived for 30 seconds.

These values appear in the call's disconnection_reason and in the call_ended webhook. See Troubleshooting.

Latency measurement#

Each ping_pong reply you send is timed against the timestamp it echoes, and the round trip is recorded as latency.llm_websocket_network_rtt on the call. Only replies that echo a timestamp count, so always echo the value you received.

Minimal server#

import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws, req) => {
  const callId = req.url!.split("/").pop();
  const send = (m: object) => ws.send(JSON.stringify(m));

  send({ response_type: "config", config: { auto_reconnect: true, call_details: true } });
  send({ response_type: "response", response_id: 0, content: "Hi, thanks for calling. How can I help?", content_complete: true });

  ws.on("message", async (raw) => {
    const msg = JSON.parse(raw.toString());
    switch (msg.interaction_type) {
      case "ping_pong":
        send({ response_type: "ping_pong", timestamp: msg.timestamp });
        break;
      case "call_details":
        console.log("call", callId, msg.call.from_number);
        break;
      case "update_only":
        break;
      case "reminder_required":
        send({ response_type: "response", response_id: msg.response_id, content: "Are you still there?", content_complete: true });
        break;
      case "response_required": {
        const last = msg.transcript.at(-1)?.content ?? "";
        const bye = /\bbye\b/i.test(last);
        send({ response_type: "response", response_id: msg.response_id, content: bye ? "Goodbye!" : `You said: ${last}`, content_complete: true, end_call: bye });
        break;
      }
    }
  });
});
import json
from aiohttp import web, WSMsgType

async def handler(request: web.Request) -> web.WebSocketResponse:
    call_id = request.match_info["call_id"]
    ws = web.WebSocketResponse()
    await ws.prepare(request)
    await ws.send_json({"response_type": "config", "config": {"auto_reconnect": True, "call_details": True}})
    await ws.send_json({"response_type": "response", "response_id": 0,
                        "content": "Hi, thanks for calling. How can I help?", "content_complete": True})
    async for msg in ws:
        if msg.type != WSMsgType.TEXT:
            continue
        data = json.loads(msg.data)
        t = data["interaction_type"]
        if t == "ping_pong":
            await ws.send_json({"response_type": "ping_pong", "timestamp": data["timestamp"]})
        elif t == "reminder_required":
            await ws.send_json({"response_type": "response", "response_id": data["response_id"],
                                "content": "Are you still there?", "content_complete": True})
        elif t == "response_required":
            last = data["transcript"][-1]["content"] if data["transcript"] else ""
            bye = "bye" in last.lower()
            await ws.send_json({"response_type": "response", "response_id": data["response_id"],
                                "content": "Goodbye!" if bye else f"You said: {last}",
                                "content_complete": True, "end_call": bye})
    return ws

app = web.Application()
app.router.add_get("/llm-websocket/{call_id}", handler)
web.run_app(app, port=8080)

Point the agent's llm_websocket_url at wss://your-host/llm-websocket. For a real agent, replace the echo with a streaming call to your model and send each token chunk as a response frame (see Best practices).