OpenAI Realtime API Function Calling in Voice Sessions — Fix Guide (2026)
Realtime API · Function Calling Severity: High

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.

TL;DRRegister tools in 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 goes silent after tool call
Model goes silent after tool call
# 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.
function_call_output rejected — invalid item
function_call_output rejected — invalid item
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>}
Duplicate responses / echo bug
Duplicate responses / echo bug
# 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

StepSenderEvent / message
1. Register toolsClientsession.update with tools array
2. Model decides to callServerresponse.output_item.added (item.type=function_call)
3. Args streamServerresponse.function_call_arguments.delta (partial JSON)
4. Args completeServerresponse.function_call_arguments.done (data.arguments = full JSON, data.call_id)
5. Client executes toolClient(your code runs the function)
6. Send resultClientconversation.item.create with type=function_call_output, call_id, output
7. Trigger responseClientresponse.create — makes the model speak the result
8. Model speaksServerresponse.output_audio.delta chunks

Root causes (ranked by frequency)

Based on OpenAI developer reports; percentages sum to 100%.

  • 24%
    Forgot response.create after tool response. The model won't automatically resume speaking after receiving function_call_output. Send response.create explicitly.
  • 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_id must match the one from response.function_call_arguments.done. Mismatched IDs are silently dropped.
  • 12%
    Duplicate response bug. turn_detection.create_response=true plus manual response.create after 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 done event. Buffer per item_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 tools array modified mid-session. session.update mid-conversation with different tools sometimes doesn't take effect for the current in-flight response. Update between responses.

How to fix it

Fix #1

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.

realtime_tool_handler.pypython
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())
Note: The 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.
Fix #2

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.

voice_friendly_tools.pypython
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?"
    )
Note: The 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.
Fix #3

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.

response_flow_control.pypython
# ✅ 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
Note: For a conversational voice bot with occasional tool use, 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, send function_call_output, then response.create.
  • Match call_id on the output to the one from response.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, then json.loads.
  • For tool-heavy workflows, set turn_detection.create_response=false and drive responses manually to avoid duplicates.
  • Handle response.done with status=cancelled — clear input buffer and prepare for the interruption follow-up.

Frequently asked questions

Can the model call multiple tools in one response?

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.

What if a tool takes longer than a few seconds?

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).

How do I handle tool errors in a voice conversation?

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.

Can I update the tools mid-session?

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).

Do MCP servers work with the Realtime API?

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.

Related errors