OpenAI Realtime API Function Calling in Voice Sessions
Function calling in the Realtime API has a different event dance than Chat Completions. The model emits response.function_call_arguments.done, you execute the tool, respond with conversation.item.create carrying a function_call_output item, then trigger response.create. Miss any of those three steps and the model goes silent mid-conversation. Here's the full flow and the specific bugs each step produces.
By Sana K. · Last updated Aug 14, 2026 · OpenAI · Page #146
session.update. On a tool call, the model emits argument deltas ending with response.function_call_arguments.done — parse the full JSON there. Execute your tool, then send conversation.item.create with {type: "function_call_output", call_id, output}, then send response.create to prompt the model to speak the result. Skip any of the three and the voice conversation stalls.Real error messages you'll see
# Model emitted response.function_call_arguments.done, you executed the tool,
# then... silence. User waits, no audio response.
# Root cause: forgot to send response.create after conversation.item.create.
# Realtime doesn't auto-continue after tool responses like Chat Completions does.
server event: {"type":"error", "error":{"message":"Invalid item type 'tool'. Use 'function_call_output' with call_id."}}
# You sent a tool_result-style item (from Chat Completions) instead of Realtime's function_call_output shape.
# Fix: {type: "function_call_output", call_id: <original call_id>, output: <string>}
# Model responds twice — once from its own decision, once from a manual response.create after tool.
# Root cause: server_vad with create_response=true + manual response.create after tool output.
# Fix: for tool workflows, set create_response=false on turn_detection, drive responses manually.
Realtime tool call sequence
| Step | Sender | Event / message |
|---|---|---|
| 1. Register tools | Client | session.update with tools array |
| 2. Model decides to call | Server | response.output_item.added (item.type=function_call) |
| 3. Args stream | Server | response.function_call_arguments.delta (partial JSON) |
| 4. Args complete | Server | response.function_call_arguments.done (data.arguments = full JSON, data.call_id) |
| 5. Client executes tool | Client | (your code runs the function) |
| 6. Send result | Client | conversation.item.create with type=function_call_output, call_id, output |
| 7. Trigger response | Client | response.create — makes the model speak the result |
| 8. Model speaks | Server | response.output_audio.delta chunks |
Root causes (ranked by frequency)
Based on OpenAI developer reports; percentages sum to 100%.
- 24%Forgot
response.createafter tool response. The model won't automatically resume speaking after receivingfunction_call_output. Sendresponse.createexplicitly. - 19%Wrong item type on tool response. Chat Completions-style
{"role":"tool", "content":...}instead of Realtime's{"type":"function_call_output", "call_id":..., "output":...}. - 14%Missing or wrong
call_id.call_idmust match the one fromresponse.function_call_arguments.done. Mismatched IDs are silently dropped. - 12%Duplicate response bug.
turn_detection.create_response=trueplus manualresponse.createafter tool output → model responds twice. Set create_response to false for tool workflows. - 10%Tool args parsed from partial deltas. Same as Chat Completions streaming — never parse before the
doneevent. Buffer peritem_id. - 9%Tool output too large for voice. Sent a 5KB JSON blob as the output; model tries to read it verbatim in voice. Summarize to human-readable text at the tool boundary.
- 7%Async tool blocks event loop. Synchronous
time.sleep()or blocking I/O in the tool handler starves the WebSocket. Use async I/O throughout. - 5%Session
toolsarray modified mid-session.session.updatemid-conversation with different tools sometimes doesn't take effect for the current in-flight response. Update between responses.
How to fix it
Implement the full tool call cycle — done, execute, output, create
The correct 4-step handler for Realtime tool calls.
On response.function_call_arguments.done, parse the arguments, execute the tool, send back a function_call_output item via conversation.item.create, then send response.create. All four steps are required; skipping the last one is the #1 cause of "model went silent after tool" bugs.
import asyncio
import base64
import json
import os
import websockets
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
# ✅ Tool implementations
async def get_weather(city: str) -> dict:
"""Async tool — call external API, return JSON-serializable result."""
# ... real API call here ...
return {"city": city, "temp_c": 22, "condition": "Sunny"}
async def check_availability(check_in: str, check_out: str) -> dict:
# ... your DB call ...
return {"available": True, "rate_usd": 189}
TOOLS_IMPL = {
"get_weather": get_weather,
"check_availability": check_availability,
}
# ✅ Session config — register tools + disable auto-response so we drive manually
SESSION_CONFIG = {
"type": "session.update",
"session": {
"instructions": (
"You are a concise voice concierge for a hotel. "
"Use tools when the user asks about weather or availability. "
"Speak the tool results naturally, don't recite JSON."
),
"audio": {
"input": {"format": "pcm16"},
"output": {"format": "pcm16", "voice": "cedar"},
},
"turn_detection": {
"type": "server_vad",
"silence_duration_ms": 200,
"create_response": True, # user speech → auto-respond
"interrupt_response": True,
},
"tools": [
{
"type": "function",
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
{
"type": "function",
"name": "check_availability",
"description": "Check hotel availability for dates",
"parameters": {
"type": "object",
"properties": {
"check_in": {"type": "string", "format": "date"},
"check_out": {"type": "string", "format": "date"},
},
"required": ["check_in", "check_out"],
},
},
],
"tool_choice": "auto",
},
}
async def main():
url = "wss://api.openai.com/v1/realtime?model=gpt-realtime"
fn_arg_buffers: dict[str, list[str]] = {} # item_id → JSON arg chunks
fn_call_meta: dict[str, dict] = {} # item_id → {name, call_id}
async with websockets.connect(
url,
additional_headers={
"Authorization": f"Bearer {OPENAI_API_KEY}",
"OpenAI-Beta": "realtime=v1",
},
max_size=None,
) as ws:
# 1. Configure the session
await ws.send(json.dumps(SESSION_CONFIG))
# 2. Consume events
async for msg in ws:
event = json.loads(msg)
t = event["type"]
if t == "response.output_item.added":
item = event["item"]
if item.get("type") == "function_call":
fn_call_meta[item["id"]] = {
"name": item["name"],
"call_id": item["call_id"],
}
fn_arg_buffers[item["id"]] = []
elif t == "response.function_call_arguments.delta":
# BUFFER, don't parse
fn_arg_buffers.setdefault(event["item_id"], []).append(
event.get("delta", "")
)
elif t == "response.function_call_arguments.done":
# ✅ FULL 4-STEP TOOL HANDLING
await handle_tool_call(
ws=ws,
call_id=event["call_id"],
name=event["name"],
arguments_json=event["arguments"], # full JSON here
)
elif t == "response.output_audio.delta":
# base64 audio to speaker
audio_bytes = base64.b64decode(event["delta"])
await your_audio_sink(audio_bytes)
elif t == "error":
print(f"ERROR: {event['error']}")
async def handle_tool_call(ws, call_id: str, name: str, arguments_json: str):
"""The 4-step dance: parse → execute → output → create."""
# Step 1 — parse
try:
args = json.loads(arguments_json)
except json.JSONDecodeError as e:
return await send_tool_error(ws, call_id, f"Invalid arguments: {e}")
# Step 2 — execute
impl = TOOLS_IMPL.get(name)
if impl is None:
return await send_tool_error(ws, call_id, f"Unknown tool: {name}")
try:
result = await impl(**args)
# Compact output — voice models struggle with huge JSON
output_str = json.dumps(result, ensure_ascii=False)[:800]
except Exception as e:
output_str = json.dumps({"error": str(e)})
# Step 3 — send function_call_output as a conversation item
await ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id, # MUST match the model's call_id
"output": output_str,
},
}))
# Step 4 — trigger response.create so the model speaks the result
await ws.send(json.dumps({"type": "response.create"}))
async def send_tool_error(ws, call_id: str, err: str):
await ws.send(json.dumps({
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": call_id,
"output": json.dumps({"error": err}),
},
}))
await ws.send(json.dumps({"type": "response.create"}))
if __name__ == "__main__":
asyncio.run(main())
response.create step is the single most-missed part of the flow. Without it, the model has the tool result in its context but no signal to speak — the conversation just stalls until the user talks again.Design tool outputs for voice — short, human-readable, not raw JSON
Fixes "model reads JSON aloud" and length issues.
Voice model output is spoken; anything you send as tool output may be recited verbatim. Raw JSON, long field names, and technical formatting break the flow. Return short natural-language strings (or minimal JSON with human-readable values), and the model will paraphrase naturally.
from datetime import date
import json
# ❌ BAD — voice model may read this literally
def bad_availability(check_in: str, check_out: str) -> dict:
return {
"success": True,
"data": {
"available_rooms": [
{"room_id": "STD-001", "type": "standard", "price_per_night_usd": 189.00, "capacity_adults": 2, "amenities_list": ["wifi", "tv", "safe"]},
{"room_id": "STD-002", "type": "standard", "price_per_night_usd": 189.00, "capacity_adults": 2, "amenities_list": ["wifi", "tv", "safe"]},
],
"total_nights": 3,
"estimated_total_usd": 567.00,
},
"metadata": {"request_id": "req_abc123", "cached": False, "ttl_seconds": 300},
}
# ✅ GOOD — voice-friendly, short, natural language
def good_availability(check_in: str, check_out: str) -> str:
# Do the real query
rooms_available = query_db_for_availability(check_in, check_out)
nights = (date.fromisoformat(check_out) - date.fromisoformat(check_in)).days
if not rooms_available:
return "No rooms are available for those dates."
price = rooms_available[0].nightly_rate
total = price * nights
return (
f"Yes, standard rooms are available for those {nights} nights "
f"at ${price} per night — about ${total} total."
)
# ✅ Structured but minimal — model will paraphrase
def compact_availability(check_in: str, check_out: str) -> dict:
return {
"available": True,
"nights": 3,
"rate_per_night_usd": 189,
"total_usd": 567,
}
# ✅ Truncation helper for long results
MAX_TOOL_OUTPUT_CHARS = 800
def voice_safe(output) -> str:
"""Format tool output for voice — string, truncated, no raw IDs."""
if isinstance(output, str):
text = output
else:
text = json.dumps(output, ensure_ascii=False)
if len(text) > MAX_TOOL_OUTPUT_CHARS:
text = text[:MAX_TOOL_OUTPUT_CHARS] + " [more details available in the app]"
return text
# ✅ Summary + optional deep-drill pattern for voice
def summarize_and_offer_details(items: list) -> str:
"""Summarize for voice; store details for later drill-down."""
if not items:
return "I didn't find any matches."
if len(items) == 1:
it = items[0]
return f"I found one match: {it.name} at ${it.price}."
return (
f"I found {len(items)} matches. The top three are: "
+ ", ".join(f"{it.name} at ${it.price}" for it in items[:3])
+ ". Would you like details on any of them?"
)
voice_safe pattern is worth applying at the tool-handler boundary — every tool output goes through it before being sent as function_call_output. Even good tool implementations produce voice-hostile output occasionally; the wrapper is cheap insurance.Disable auto-response for pure tool workflows to avoid duplicate responses
Fixes model responding twice or interrupting itself.
When turn_detection.create_response=true AND you manually send response.create after a tool, the model may generate two responses. For workflows where the model is expected to call a tool for nearly every turn, set create_response=false and drive responses manually. For mixed workflows (some turns talk, some call tools), keep auto-response on and rely on the model to skip response.create-after-tool when its own auto-response already covered it.
# ✅ Manual-response mode — for tool-heavy workflows
MANUAL_RESPONSE_SESSION = {
"type": "session.update",
"session": {
"audio": {"input": {"format": "pcm16"}, "output": {"format": "pcm16"}},
"turn_detection": {
"type": "server_vad",
"silence_duration_ms": 200,
"create_response": False, # ← don't auto-respond
"interrupt_response": True,
},
"tools": [...],
},
}
async def with_manual_responses(ws):
"""You control WHEN the model responds — after user speech OR after tool output."""
async for msg in ws:
event = json.loads(msg)
t = event["type"]
if t == "input_audio_buffer.speech_stopped":
# User done speaking — decide whether to respond directly or wait
await ws.send(json.dumps({"type": "response.create"}))
elif t == "response.function_call_arguments.done":
# Handle the tool then trigger response
await handle_tool_call(ws, event["call_id"], event["name"], event["arguments"])
# response.create is inside handle_tool_call
# ✅ Auto-response mode — for chat-heavy workflows with occasional tools
AUTO_RESPONSE_SESSION = {
"type": "session.update",
"session": {
"audio": {"input": {"format": "pcm16"}, "output": {"format": "pcm16"}},
"turn_detection": {
"type": "server_vad",
"silence_duration_ms": 200,
"create_response": True, # ← auto-respond to speech
"interrupt_response": True,
},
"tools": [...],
},
}
# In auto-response mode, tools still need response.create after output,
# because the auto-response for the user's turn has already completed
# (with the tool call decision), and a new response is needed to speak
# the tool result. The interrupt_response=True setting handles the case
# where a user starts talking before the model can respond.
# ✅ Response cancellation — user interrupted mid-response
# Server sends: response.done with status="cancelled" when interrupted
# Your handler:
async def on_response_cancelled(ws, event):
"""User interrupted; discard pending audio buffer on client side."""
await ws.send(json.dumps({"type": "input_audio_buffer.clear"}))
# ✅ Explicit conversation.item.truncate for partial audio delivery
# When you know the user interrupted mid-word:
await ws.send(json.dumps({
"type": "conversation.item.truncate",
"item_id": current_response_item_id,
"content_index": 0,
"audio_end_ms": played_ms, # how much audio actually reached the user
}))
# ✅ Avoid response.create while a response is already in progress
# Server rejects with:
# error: {"message": "A response is already active. Cancel it before starting a new one."}
# Track state:
class ResponseState:
def __init__(self):
self.active = False
async def start(self, ws):
if self.active:
# Cancel first
await ws.send(json.dumps({"type": "response.cancel"}))
self.active = True
await ws.send(json.dumps({"type": "response.create"}))
def on_done(self):
self.active = False
create_response=true plus explicit response.create after tool output works fine — the model handles the two-phase interaction cleanly. Only switch to manual mode when you're seeing duplicate responses or need finer control.Prevention checklist
- Every tool call needs four steps: parse args on
done, execute, sendfunction_call_output, thenresponse.create. - Match
call_idon the output to the one fromresponse.function_call_arguments.done. - Use
{"type":"function_call_output"}, not Chat Completions{"role":"tool"}shape. - Design tool outputs for voice: short natural-language strings, not raw JSON dumps. Cap at ~800 chars.
- Never parse function arguments from partial deltas. Buffer until
done, thenjson.loads. - For tool-heavy workflows, set
turn_detection.create_response=falseand drive responses manually to avoid duplicates. - Handle
response.donewithstatus=cancelled— clear input buffer and prepare for the interruption follow-up.
Frequently asked questions
Yes — the model can emit multiple function_call items in one response, each with its own call_id. You handle each independently: parse args, execute, send function_call_output. Trigger response.create only once after ALL tool outputs are sent. If you trigger after each, the model will speak between tool completions, which is usually not what you want.
The user is hearing silence. Bridge the gap with a filler response before executing the slow tool — after receiving the tool call, send response.create with instructions like "Say 'let me check that for you'" first, then execute the tool in parallel, then send the actual result. Requires manual response management (auto-response off).
Return the error as tool output: {"error": "Service unavailable, please try again"}. The model will paraphrase it into natural speech ("I couldn't reach the booking system right now, could we try again in a moment?"). Don't raise exceptions to the client — they break the voice flow and the model can't recover gracefully.
Yes — send another session.update with a new tools array. Takes effect for the next response, not the currently in-flight one. Useful for progressive disclosure ("start with limited tools; unlock more as the conversation matures"), personalization ("load user-specific tools after login"), and cost control (don't send 20 tool schemas every turn).
Support varies by rollout — verify current status. The general pattern: your server-side agent (via WebSocket transport) mediates between the Realtime session and MCP servers, translating tool calls to MCP invocations. Direct MCP tool declaration on Realtime sessions is less common than on the Responses API today. Check the current Realtime + MCP docs before designing.