This guide shows how to integrate the AgentDuet SDK with xAI’s Grok Voice Realtime API for bidirectional real-time speech-to-speech streaming, barge-in, and optional tools.
What this integration provides
A phone agent that answers calls, streams 24 kHz PCM to Grok, plays Grok audio back through a non-blocking playback queue, flushes AgentDuet’s outbound buffer on barge-in, supports a hang_up tool, and closes the WebSocket on hangup.
Prerequisites
- Python 3.12+
- Get an API key and connector UUID at agentduet.com
- xAI API key (xAI console)
pip install "agentduet==1.0.0" websockets python-dotenv
Step 1: Create a project folder
mkdir agentduet-grok-voice-bridge && cd $_python3.12 -m venv .venv && source .venv/bin/activatepip install "agentduet==1.0.0" websockets python-dotenv
Step 2: Configure .env
cat > .env << 'EOF'AGENTDUET_API_KEY=your-connector-api-keyAGENTDUET_CONNECTOR_UUID=your-connector-uuidXAI_API_KEY=GROK_VOICE=leoEOF
Step 3: Write grok_voice_bridge.py
Create the file next to .env:
from __future__ import annotationsimport asyncioimport base64import jsonimport loggingimport osfrom typing import Any, Optionalimport websocketsfrom dotenv import load_dotenvfrom websockets.asyncio.client import ClientConnectionfrom websockets.exceptions import ConnectionClosed, InvalidStatusfrom agentduet import (BufferFullError,Call,CallAudioConfig,CallClosedError,IncomingCallNotification,SessionManager,SessionManagerConfig,new_session_id,)load_dotenv()logging.basicConfig(level=logging.INFO,format="%(asctime)s %(name)s %(levelname)s %(message)s",)logger = logging.getLogger(__name__)AGENTDUET_API_KEY = os.environ["AGENTDUET_API_KEY"]AGENTDUET_CONNECTOR_UUID = os.environ["AGENTDUET_CONNECTOR_UUID"]XAI_API_KEY = os.environ["XAI_API_KEY"]GROK_MODEL = "grok-voice-think-fast-1.0"GROK_REALTIME_URL = f"wss://api.x.ai/v1/realtime?model={GROK_MODEL}"GROK_SAMPLE_RATE = 24000GROK_VOICE = os.environ.get("GROK_VOICE", "leo").lower()AGENT_NAME = "Grok"SYSTEM_PROMPT = (f"Your name is {AGENT_NAME}. You are a witty, helpful voice assistant on a phone call. ""Greet the caller briefly, keep answers short and conversational, and ask clarifying ""questions when needed. When the caller wants to hang up or says goodbye, say a brief ""goodbye and call the hang_up tool.")HANG_UP_TOOL = {"type": "function","name": "hang_up","description": ("End the phone call. Use when the caller asks to hang up, ""end the call, or says goodbye and wants to leave."),"parameters": {"type": "object", "properties": {}, "required": []},}class GrokLiveIntegration:"""Bidirectional audio bridge: AgentDuet Call ↔ xAI Grok Realtime."""def __init__(self, call: Call, grok_ws: ClientConnection):self._call = callself._grok_ws = grok_wsself._send_to_grok_task: Optional[asyncio.Task] = Noneself._recv_from_grok_task: Optional[asyncio.Task] = Noneself._playback_task: Optional[asyncio.Task] = Noneself._terminated = False# Playback must not block the Grok event loop, or speech_started# arrives too late for clear_send_audio_buffer() to help.self._playback_gen = 0self._audio_queue: asyncio.Queue[tuple[int, bytes] | None] = asyncio.Queue()self._active_response_id: Optional[str] = Noneself._cancelled_response_ids: set[str] = set()async def _on_hangup(self, _evt: Any) -> None:logger.info("Call %s hung up", self._call.id)self._terminated = Truetry:await self._grok_ws.close()except Exception:logger.exception("Error closing Grok WebSocket")for task in (self._send_to_grok_task,self._recv_from_grok_task,self._playback_task,):if task and not task.done():task.cancel()try:await taskexcept asyncio.CancelledError:passasync def run(self) -> None:self._call.on_hangup(self._on_hangup)await self._configure_session()await self._greet_caller()self._playback_task = asyncio.create_task(self._playback_worker())self._send_to_grok_task = asyncio.create_task(self._stream_to_grok())self._recv_from_grok_task = asyncio.create_task(self._receive_from_grok())await asyncio.gather(self._send_to_grok_task,self._recv_from_grok_task,return_exceptions=True,)if self._playback_task and not self._playback_task.done():self._audio_queue.put_nowait(None)try:await self._playback_taskexcept asyncio.CancelledError:passasync def _configure_session(self) -> None:await self._grok_ws.send(json.dumps({"type": "session.update","session": {"voice": GROK_VOICE,"instructions": SYSTEM_PROMPT,"reasoning": {"effort": "none"},"turn_detection": {"type": "server_vad","threshold": 0.5,"silence_duration_ms": 300,"prefix_padding_ms": 200,},"tools": [HANG_UP_TOOL],"audio": {"input": {"format": {"type": "audio/pcm","rate": GROK_SAMPLE_RATE,},},"output": {"format": {"type": "audio/pcm","rate": GROK_SAMPLE_RATE,},},},},}))async def _greet_caller(self) -> None:await self._grok_ws.send(json.dumps({"type": "response.create","response": {"instructions": (f"Greet the caller warmly. Introduce yourself as {AGENT_NAME} ""and ask how you can help today."),},}))async def _stream_to_grok(self) -> None:try:async for chunk in self._call.caller.audio_stream():if self._terminated:breakawait self._grok_ws.send(json.dumps({"type": "input_audio_buffer.append","audio": base64.b64encode(chunk).decode("ascii"),}))except (CallClosedError, ConnectionClosed):passexcept asyncio.CancelledError:raiseexcept Exception:logger.exception("Error streaming caller audio to Grok")raiseasync def _playback_worker(self) -> None:while True:item = await self._audio_queue.get()if item is None:breakgen, audio = itemif gen != self._playback_gen:continuetry:await self._call.send_audio(audio)except BufferFullError:logger.warning("Outgoing buffer full - dropping chunk")except CallClosedError:breakexcept asyncio.CancelledError:raiseasync def _flush_playback(self) -> None:self._playback_gen += 1self._active_response_id = Nonewhile not self._audio_queue.empty():try:self._audio_queue.get_nowait()except asyncio.QueueEmpty:breaktry:await self._call.clear_send_audio_buffer()except CallClosedError:returnasync def _agent_hang_up(self) -> None:logger.info("Agent hanging up call %s", self._call.id)try:for _ in range(40): # up to ~4s for goodbye audio to drainif await self._call.get_send_audio_buffer_size() == 0:breakawait asyncio.sleep(0.1)except CallClosedError:returnresult = await self._call.close()if not result:logger.error("Hang up failed for %s: %s (%s)",self._call.id,result.error_message,result.error_code,)async def _receive_from_grok(self) -> None:try:async for raw_event in self._grok_ws:if self._terminated:breakevent = json.loads(raw_event)etype = event.get("type")if etype == "response.created":self._active_response_id = event.get("response", {}).get("id")elif etype == "input_audio_buffer.speech_started":try:buf_size = await self._call.get_send_audio_buffer_size()except CallClosedError:buf_size = 0should_flush = (self._active_response_id is not Noneor buf_size > 0or self._audio_queue.qsize() > 0)if should_flush:if self._active_response_id is not None:self._cancelled_response_ids.add(self._active_response_id)logger.info("Caller interrupted - stopping playback")await self._flush_playback()elif etype == "response.done":response_id = event.get("response", {}).get("id")if response_id:self._cancelled_response_ids.discard(response_id)if response_id == self._active_response_id:self._active_response_id = Noneelif etype in ("response.output_audio.delta", "response.audio.delta"):response_id = event.get("response_id")if response_id and response_id in self._cancelled_response_ids:continueif (response_idand self._active_response_idand response_id != self._active_response_id):continueaudio = base64.b64decode(event["delta"])self._audio_queue.put_nowait((self._playback_gen, audio))elif etype == "response.function_call_arguments.done":if event.get("name") != "hang_up":logger.warning("Unknown tool: %s", event.get("name"))continueawait self._grok_ws.send(json.dumps({"type": "conversation.item.create","item": {"type": "function_call_output","call_id": event["call_id"],"output": json.dumps({"status": "hanging_up"}),},}))await self._agent_hang_up()returnelif etype == "error":logger.error("Grok error event: %s", event)except (CallClosedError, ConnectionClosed):passexcept asyncio.CancelledError:raiseexcept Exception:logger.exception("Error receiving from Grok")raisefinally:self._audio_queue.put_nowait(None)async def bridge_call_to_grok(call: Call) -> None:try:async with websockets.connect(GROK_REALTIME_URL,additional_headers={"Authorization": f"Bearer {XAI_API_KEY}"},open_timeout=15,) as grok_ws:result = await call.answer()if not result:logger.error("Answer failed for call %s: %s (%s)",call.id,result.error_message,result.error_code,)returnawait GrokLiveIntegration(call, grok_ws).run()except InvalidStatus as e:body = getattr(e.response, "body", b"") or b""detail = body.decode("utf-8", errors="replace") if body else str(e)logger.error("Grok WebSocket rejected (HTTP %s). Check XAI_API_KEY - %s",e.response.status_code,detail,)except Exception:logger.exception("Failed during Grok bridge for call %s", call.id)async def main() -> None:config = SessionManagerConfig.create(api_key=AGENTDUET_API_KEY,connector_uuid=AGENTDUET_CONNECTOR_UUID,call_audio=CallAudioConfig(sample_rate=GROK_SAMPLE_RATE,buffer_size=1024 * 1024,),)async with SessionManager(config) as sm:logger.info("Connected. Waiting for calls...")@sm.on_incoming_callasync def on_call(noti: IncomingCallNotification) -> None:logger.info("Incoming call %s from %s", noti.call_id, noti.participant)session = await sm.open_session(new_session_id(), noti.subscriber)call = await session.process_call(noti)try:await bridge_call_to_grok(call)finally:await call.close()await sm.run_forever()if __name__ == "__main__":asyncio.run(main())
Step 4: Run the bridge
python grok_voice_bridge.py
Keep the process running. Confirm logs show the SessionManager connected and waiting for calls.
Step 5: Call your number
Dial the connector phone number. Verify two-way audio, then interrupt mid-reply to confirm barge-in clears playback. Hang up and confirm the process remains ready for the next call.
How the pieces fit
SessionManager receives the inbound call → you open_session + process_call → answer() → two tasks move PCM between AgentDuet and the model. Hangup closes the model session and cancels those tasks. Telephony stays AgentDuet’s job; the model never sees SIP.
Notes
- Non-blocking playback: Queue audio deltas and send from a worker. If
send_audioblocks the receive loop,speech_startedarrives too late and barge-in sounds laggy. - Interrupt: On
input_audio_buffer.speech_started, bump_playback_gen, drain the local queue, andawait call.clear_send_audio_buffer(). - Flush even after
response.done: Grok may finish generating before AgentDuet finishes playing; flush when buffer or queue still has audio. - Warm the WebSocket before
answer(): Connect first so the greeting is not delayed by TLS/handshake. - Credits:
InvalidStatususually means a bad key or missing team credits at the xAI console.
