Voice Agents

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_s16le
pcm_s16le is signed 16-bit little-endian PCM. mulaw is 8 kHz G.711, which lets a SIP leg pass its audio through untouched.
audio_format.sample_rateintegerdefault: 16000
8000, 16000, or 24000 for pcm_s16le. With mulaw the rate is forced to 8000 — any other value is rejected.
audio_format.containerstringdefault: raw
Always raw. Frames carry no header.
variablesobject
Substituted into the agent's instructions and welcome message for this call only. At most 32 entries; names match [A-Za-z0-9_]+ and are at most 64 characters; values at most 1024 characters; 8192 characters across all of them.

Response

call_idstring
The call's id. This is the id the Calls tab and the API use.
websocket_urlstring
Connect to this verbatim. It carries a single-use token.
audio_formatobject
The negotiated format — what you must send and what you will receive.
expires_atstring
The URL is valid for about two minutes.

The 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_s16le that is 320 samples, 640 bytes.
  • Text frames are one JSON message each, always with a type and a monotonically increasing event_id.

Wait for call.started before sending audio.

Server → client

typePayloadWhat to do
call.startedcall_id, audio_formatThe first message. Audio may flow after it.
transcriptrole, segment_id, text, finalLive transcription. Interim results share a segment_id; final marks the settled text.
agent.statestate — listening, thinking, or speakingDrive UI or hold music.
interruption—The caller barged in. Drop any buffered agent audio you have not played yet.
dtmfdigitsThe agent is sending keypad tones — forward them to your telephony leg.
transferdestination, labelThe agent is handing the call over. Execute the transfer on your PBX, and answer within 15 seconds.
tool_calltool_call_id, name, arguments, expects_responseRun the client tool and reply with tool_result.
ping—Keepalive, every 15 seconds. Reply with pong.
call.endedreasonThe call is over.
errorcode, messageSomething 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

typePayloadUse
pong—Answer to ping. Required — a socket that stops answering is closed.
hangup—End the call from your side.
dtmfdigitsThe caller pressed keys. At most 64 characters.
contexttext, role, trigger_responsePush information into the conversation mid-call.
tool_resulttool_call_id, and either result or errorAnswer 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

LimitValue
Maximum frame size1 MiB
Keepalive pingevery 15 s
Idle timeout60 s without a read
Transfer response deadline15 s
Tool-call backstop5 minutes
Time for the agent to join20 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.

Getting it right

Where to go next

On this page