OpenAI Realtime

View as Markdown

This guide shows how to integrate the AgentDuet SDK with the OpenAI Agents SDK and Realtime API for bidirectional real-time audio streaming, barge-in, and tool calling.

What this integration provides

A phone agent that answers incoming calls, streams 24 kHz PCM to an OpenAI Realtime session, plays model audio back, clears the outbound buffer on audio_interrupted, and closes the Realtime session on hangup.

Prerequisites

  • Python 3.12+
  • Get an API key and connector UUID at agentduet.com
  • OpenAI API key with Realtime access (API keys)
$pip install "agentduet==1.0.0" "openai-agents" python-dotenv

Step 1: Create a project folder

$mkdir agentduet-openai-realtime-bridge && cd $_
$python3.12 -m venv .venv && source .venv/bin/activate
$pip install "agentduet==1.0.0" "openai-agents" python-dotenv

Step 2: Configure .env

$cat > .env << 'EOF'
$AGENTDUET_API_KEY=your-connector-api-key
$AGENTDUET_CONNECTOR_UUID=your-connector-uuid
$OPENAI_API_KEY=sk-...
$EOF

Step 3: Write openai_realtime_bridge.py

Create the file next to .env:

1import asyncio
2import logging
3import os
4from typing import Optional
5
6from agents.realtime import RealtimeAgent, RealtimeRunner
7from agents.realtime.session import RealtimeSession
8from dotenv import load_dotenv
9
10from agentduet import (
11 BufferFullError,
12 Call,
13 CallAudioConfig,
14 CallClosedError,
15 IncomingCallNotification,
16 SessionManager,
17 SessionManagerConfig,
18 new_session_id,
19)
20
21load_dotenv()
22
23logging.basicConfig(
24 level=logging.INFO,
25 format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
26)
27logger = logging.getLogger(__name__)
28
29AGENT = RealtimeAgent(
30 name="Assistant",
31 instructions="You are a helpful and friendly AI assistant.",
32)
33
34RUNNER = RealtimeRunner(
35 starting_agent=AGENT,
36 config={
37 "model_settings": {
38 "model_name": "gpt-realtime-1.5",
39 "audio": {
40 "input": {
41 "format": "pcm16",
42 "transcription": {"model": "gpt-4o-mini-transcribe"},
43 "turn_detection": {
44 "type": "semantic_vad",
45 "interrupt_response": True,
46 },
47 },
48 "output": {
49 "format": "pcm16",
50 "voice": "ash",
51 },
52 },
53 }
54 },
55)
56
57
58class OpenAIRealtimeIntegration:
59 def __init__(self, call: Call, session: RealtimeSession):
60 self._call = call
61 self._session = session
62 self._send_task: Optional[asyncio.Task] = None
63 self._recv_task: Optional[asyncio.Task] = None
64 self._terminated = False
65
66 async def _on_hangup(self, evt):
67 logger.info("Call terminated - closing OpenAI Realtime session")
68 self._terminated = True
69 try:
70 await self._session.close()
71 except Exception:
72 logger.exception("Error closing OpenAI Realtime session")
73
74 tasks = [t for t in (self._send_task, self._recv_task) if t]
75 for t in tasks:
76 t.cancel()
77 if tasks:
78 await asyncio.gather(*tasks, return_exceptions=True)
79
80 async def run(self):
81 self._call.on_hangup(self._on_hangup)
82 self._send_task = asyncio.create_task(self.stream_to_openai())
83 self._recv_task = asyncio.create_task(self.receive_from_openai())
84 await asyncio.gather(self._send_task, self._recv_task, return_exceptions=True)
85
86 async def stream_to_openai(self):
87 try:
88 async for audio_chunk in self._call.caller.audio_stream():
89 await self._session.send_audio(audio_chunk)
90 except asyncio.CancelledError:
91 raise
92 except CallClosedError:
93 logger.debug("Call closed; stopping stream to OpenAI")
94 except Exception:
95 logger.exception("Error in stream to OpenAI")
96 raise
97
98 async def receive_from_openai(self):
99 try:
100 async for event in self._session:
101 if event.type == "audio":
102 audio_bytes = event.audio.data
103 if audio_bytes:
104 try:
105 await self._call.send_audio(audio_bytes)
106 except BufferFullError:
107 logger.warning(
108 "Send buffer full - drop chunk or raise buffer_size"
109 )
110 elif event.type == "audio_interrupted":
111 await self._call.clear_send_audio_buffer()
112 logger.debug("OpenAI interrupted - cleared send buffer")
113 elif event.type == "error":
114 logger.error("OpenAI Realtime error: %s", event.error)
115 except asyncio.CancelledError:
116 raise
117 except CallClosedError:
118 logger.debug("Call closed; stopping receive from OpenAI")
119 except Exception:
120 logger.exception("Error in receive from OpenAI")
121 raise
122
123
124async def main():
125 config = SessionManagerConfig.create(
126 api_key=os.getenv("AGENTDUET_API_KEY"),
127 connector_uuid=os.getenv("AGENTDUET_CONNECTOR_UUID"),
128 call_audio=CallAudioConfig(
129 sample_rate=24000,
130 buffer_size=1024 * 1024,
131 ),
132 )
133
134 async with SessionManager(config) as sm:
135 logger.info("Connected. Waiting for calls...")
136
137 @sm.on_incoming_call
138 async def on_call(noti: IncomingCallNotification):
139 session = await sm.open_session(new_session_id(), noti.subscriber)
140 call = await session.process_call(noti)
141 logger.info("Incoming call %s", call.id)
142 try:
143 async with await RUNNER.run() as oai_session:
144 result = await call.answer()
145 if not result:
146 logger.error(
147 "Answer failed %s: %s (%s)",
148 call.id,
149 result.error_message,
150 result.error_code,
151 )
152 return
153 await OpenAIRealtimeIntegration(call, oai_session).run()
154 except Exception:
155 logger.exception("Error in OpenAI Realtime bridge")
156 await call.close()
157 raise
158
159 await sm.run_forever()
160
161
162if __name__ == "__main__":
163 asyncio.run(main())

Step 4: Run the bridge

$python openai_realtime_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_callanswer() → 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

  • Sample rate: Keep AgentDuet and Realtime at 24 kHz pcm16.
  • Interrupt: On audio_interrupted, use await call.clear_send_audio_buffer(). Do not call private _interrupt helpers.
  • Semantic VAD: interrupt_response: True lets the model cancel its turn; you still must clear AgentDuet’s outbound buffer or the caller hears leftover audio.
  • BufferFullError: Increase CallAudioConfig.buffer_size or drop chunks under bursty TTS.
  • Session lifecycle: Open the Realtime session before answer() so the first greeting is not delayed by WebSocket setup.