WebSocket Transport
Stream raw audio to a Dynamiq voice agent over a single WebSocket — the full message protocol, audio formats, and a runnable client.
The WebSocket transport is for backends that already have the audio: a telephony or SIP stack, a contact-center platform, a custom media server. You stream raw audio frames over one socket; JSON text frames carry everything else — transcripts, tool calls, transfers, hangup.
Use WebRTC instead when the caller is a browser or a mobile app, because there the client SDK handles microphone capture, playback, and network adaptation for you.
The agent must be deployed.
The flow
Create the call on your server
POST /v1/agents/voice/agents/{agent_id}/calls/websocket with a Personal Access Token. It returns a single-use WebSocket URL.
Connect to the returned URL
Open it verbatim from whichever side holds the audio. Authentication is the token already embedded in the URL — no headers.
Stream audio and handle events
Send 20 ms binary audio frames, play what comes back, and act on the JSON events.
1. Create the call
curl -X POST "https://api.getdynamiq.ai/v1/agents/voice/agents/$AGENT_ID/calls/websocket" \
-H "Authorization: Bearer $DYNAMIQ_PAT" \
-H "Dynamiq-Org-Id: $DYNAMIQ_ORG_ID" \
-H "Content-Type: application/json" \
-d '{
"audio_format": {"encoding": "pcm_s16le", "sample_rate": 16000},
"variables": {"customer_name": "Ada"}
}'{
"data": {
"call_id": "0f9c1d3a-5b7e-4a21-9c84-2d6f1b0e7a35",
"websocket_url": "wss://api.getdynamiq.ai/v1/agents/voice/calls/0f9c1d3a-5b7e-4a21-9c84-2d6f1b0e7a35/transport?token=9a1f...",
"audio_format": {"encoding": "pcm_s16le", "sample_rate": 16000, "container": "raw"},
"expires_at": "2026-09-14T10:32:00Z"
}
}# pip install requests
import os
import requests
BASE_URL = "https://api.getdynamiq.ai"
AGENT_ID = os.environ["DYNAMIQ_VOICE_AGENT_ID"]
ORG_ID = os.environ["DYNAMIQ_ORG_ID"]
PAT = os.environ["DYNAMIQ_PAT"] # dyn_pat_… — create one under Settings → Access Tokens
def create_call(variables: dict | None = None) -> dict:
"""Reserve a call and get its single-use socket URL. Run this on your server."""
response = requests.post(
f"{BASE_URL}/v1/agents/voice/agents/{AGENT_ID}/calls/websocket",
headers={
"Authorization": f"Bearer {PAT}",
"Dynamiq-Org-Id": ORG_ID,
"Content-Type": "application/json",
},
json={
# pcm_s16le at 8000, 16000 or 24000 Hz.
# A SIP leg sends {"encoding": "mulaw"} instead: 8 kHz G.711 as-is.
"audio_format": {"encoding": "pcm_s16le", "sample_rate": 16000},
# Optional: substituted into the agent's prompt for this call only.
"variables": variables or {},
},
timeout=30,
)
response.raise_for_status()
return response.json()["data"]Request body
audio_format.encodingpcm_s16le | mulawdefault: pcm_s16leaudio_format.sample_rateintegerdefault: 16000audio_format.containerstringdefault: rawvariablesobjectResponse
call_idstringwebsocket_urlstringaudio_formatobjectexpires_atstringThe URL is single-use and short-lived. Create the call at the moment you're ready to connect, not ahead of time, and connect once — a second attempt with the same URL is rejected, as is one made after it expires.
2. Connect and stream
Connecting can be refused with 503 and {"code": "capacity"}. That is the connect step, not the create step: the server that would host the call has no free slot. Your reservation is not consumed when this happens, so create a new call and connect again rather than treating it as a failed call.
Once connected, the socket carries two kinds of frame:
- Binary frames are audio, in the negotiated format, in both directions. Send 20 ms per frame — at 16 kHz
pcm_s16lethat is 320 samples, 640 bytes. - Text frames are one JSON message each, always with a
typeand a monotonically increasingevent_id.
Wait for call.started before sending audio.
Server → client
type | Payload | What to do |
|---|---|---|
call.started | call_id, audio_format | The first message. Audio may flow after it. |
transcript | role, segment_id, text, final | Live transcription. Interim results share a segment_id; final marks the settled text. |
agent.state | state — listening, thinking, or speaking | Drive UI or hold music. |
interruption | — | The caller barged in. Drop any buffered agent audio you have not played yet. |
dtmf | digits | The agent is sending keypad tones — forward them to your telephony leg. |
transfer | destination, label | The agent is handing the call over. Execute the transfer on your PBX, and answer within 15 seconds. |
tool_call | tool_call_id, name, arguments, expects_response | Run the client tool and reply with tool_result. |
ping | — | Keepalive, every 15 seconds. Reply with pong. |
call.ended | reason | The call is over. |
error | code, message | Something failed; a call.ended always follows. |
call.ended reasons: agent_hangup, client_hangup, room_closed, error, server_shutdown. Error codes: agent_unavailable, room_failed.
Client → server
type | Payload | Use |
|---|---|---|
pong | — | Answer to ping. Required — a socket that stops answering is closed. |
hangup | — | End the call from your side. |
dtmf | digits | The caller pressed keys. At most 64 characters. |
context | text, role, trigger_response | Push information into the conversation mid-call. |
tool_result | tool_call_id, and either result or error | Answer a tool_call. |
Pushing context mid-call
context is the most useful message in the protocol and has no equivalent in a plain telephony bridge. It injects information into the live conversation — your CRM finished a lookup, an order status changed, the caller was identified.
{"type": "context", "text": "The caller's order shipped today.", "role": "system"}With role: "system" (the default) and trigger_response: false, the agent silently learns the fact and uses it when relevant. Set trigger_response: true to make it speak immediately. Use role: "user" to inject something as if the caller had said it:
{"type": "context", "text": "Where is my order?", "role": "user", "trigger_response": true}Text is capped at 4096 characters.
Answering a tool call
{"type": "tool_call", "event_id": 42, "tool_call_id": "call_7f3a", "name": "check_account_balance", "arguments": {"account_id": "417"}, "expects_response": true}Reply on the same socket:
{"type": "tool_result", "tool_call_id": "call_7f3a", "result": {"balance_usd": 128.4}}Send error instead of result to tell the agent why you couldn't — it treats that as a recoverable failure, explains it to the caller, and carries on. Results are capped at 15 KB. When expects_response is false the call is a notification: do the work, send nothing.
There is nothing to acknowledge. Send the tool_result whenever your handler is done, within the tool's own timeout — the transport accepts the call on your behalf the moment it forwards it, so a slow tool never stalls transcription or barge-in on the same socket.
Limits
| Limit | Value |
|---|---|
| Maximum frame size | 1 MiB |
| Keepalive ping | every 15 s |
| Idle timeout | 60 s without a read |
| Transfer response deadline | 15 s |
| Tool-call backstop | 5 minutes |
| Time for the agent to join | 20 s, after which the call ends as failed |
| Session URL validity | ~2 minutes, single use |
A minimal client
# pip install websockets
import asyncio
import json
import websockets
from create_call import create_call # from step 1
FRAME_BYTES = 640 # 20 ms of 16 kHz signed 16-bit mono PCM
async def run_call() -> None:
call = create_call({"customer_name": "Ada"})
async with websockets.connect(call["websocket_url"], max_size=2**20) as ws:
started = asyncio.Event()
async def send_audio() -> None:
"""Replace read_frame() with your telephony leg's audio source."""
await started.wait() # no audio before call.started
while True:
frame = await read_frame(FRAME_BYTES)
if frame is None:
await ws.send(json.dumps({"type": "hangup"}))
return
await ws.send(frame)
async def receive() -> None:
async for message in ws:
if isinstance(message, bytes):
await play_frame(message)
continue
event = json.loads(message)
kind = event["type"]
if kind == "call.started":
started.set()
elif kind == "ping":
await ws.send(json.dumps({"type": "pong", "event_id": event["event_id"]}))
elif kind == "interruption":
await drop_buffered_playback()
elif kind == "transcript" and event["final"]:
print(f'{event["role"]}: {event["text"]}')
elif kind == "tool_call":
result = await run_client_tool(event["name"], event["arguments"])
if event.get("expects_response", True):
await ws.send(
json.dumps(
{
"type": "tool_result",
"tool_call_id": event["tool_call_id"],
"result": result,
}
)
)
elif kind == "transfer":
await transfer_on_pbx(event["destination"], event["label"])
elif kind == "call.ended":
print(f'call ended: {event["reason"]}')
return
sender = asyncio.create_task(send_audio())
try:
await receive()
finally:
sender.cancel()
asyncio.run(run_call())Java
The Integrate tab also generates a Java client for this transport, and unlike the WebRTC one it needs no dependencies at all — java.net.http.WebSocket for the socket and javax.sound.sampled for audio, on Java 17+. Same 20 ms framing: 640-byte frames at 16 kHz.
If you are integrating from the JVM, this transport is markedly simpler than WebRTC, which has no JVM SDK and requires a native library and JDK 22+. See Deploy & integrate.
Generated clients
The Integrate tab generates this same client filled in with your agent's id, in Python, TypeScript, or Java, along with a working microphone demo you can run before wiring in your own audio.