← Back to the blog
Tutorial

Gemini 3.8 Live Function Calling: how to split args, match the id, and return a tool result

Published 2026-09-23 Updated 2026-09-23 About 16 min JSON Toolbox

How this article is organized

The previous piece, 3.8 Live from voice to Tool, only drew the five hops. This one is dated 2026-09-23. Against the official Live tools page (last updated 2026-09-15) and the WebSocket reference, it unpacks toolCall, args, and FunctionResponse so you can accept them. The Live API does not run tools for you. Pipeline shape: previous article. Why the contract layer is still an object: Why AI likes JSON.

The user says “turn on the living-room light.” When the model reaches, you do not get a transcript. You get an already-parsed object: id, name, args. Drop the id, feed a string into the executor as if it were an object, or mint a new id on the way back, and the audio may still say “okay” after the wrong room has already flipped. On the text Agent side this is required_actions → tool_result. Same shape; the shell is a Live event. See After the Agents API, why JSON matters more.

What is different from the last article

The last article stopped at “audio in, toolCall object out.” This one does three jobs:

  1. Pull functionCalls[] from the SDK or the WebSocket.
  2. Treat args as an object. Refuse extra or misheard fields with Schema.
  3. Build FunctionResponse with the same id / name. Handle cancel when it arrives.

As of 2026-09-23 the stable model string is still gemini-3.8-live. Live does not support Structured Output. BidiGenerateContentSetup.generationConfig also does not take responseSchema / responseJsonSchema. What you can pin is the function-parameter Schema, plus the check you run on args before execute. The reply-contract layer: What Structured Output is.

One line. Parsing is not printing the whole message. It is getting the object first, then deciding whether to reach.

Three JSON objects you must parse

The WebSocket reference folds client envelopes into one of four: setup, clientContent, realtimeInput, toolResponse. The tool loop uses the last. The server puts toolCall or toolCallCancellation on BidiGenerateContentServerMessage. Do not mix this with the WordInfo JSON from transcription; see 3.5 Transcribe.

Live Function Calling: three objects to parse The server emits toolCall; the client validates args then executes; toolResponse returns FunctionResponse objects with matching ids. 1. toolCall functionCalls[] 2. args Schema, then execute 3. toolResponse Match id / name
Accept each object on its own. Smash them into one “raw message” string and the next step will copy the wrong id.

Pin the field contract first:

{
  "incoming": {
    "toolCall": {
      "functionCalls": [
        { "id": "string", "name": "string", "args": {} }
      ]
    }
  },
  "outgoing": {
    "toolResponse": {
      "functionResponses": [
        { "id": "string", "name": "string", "response": {} }
      ]
    }
  },
  "cancel": {
    "toolCallCancellation": {
      "ids": ["string"]
    }
  }
}

The Python SDK folds camelCase into snake_case: response.tool_call.function_calls, session.send_tool_response(function_responses=...). The JS SDK keeps camelCase: turn.toolCall.functionCalls, session.sendToolResponse({ functionResponses }). On a raw WebSocket, JSON fields follow the camelCase in the reference.

Pull toolCall out of the session

The official Live tools page states the client job in one line: declare functions in the session config; after a call arrives, send a list of FunctionResponse with session.send_tool_response. Unlike generateContent, there is no automatic tool response. Loop shape: Agents API vs MCP — that side is a harness; this side is your own receive().

Treat it as an event first. Do not start with the audio buffer:

async for response in session.receive():
    if response.tool_call:
        for fc in response.tool_call.function_calls:
            call_id = fc.id
            name = fc.name
            args = fc.args or {}
            # next: validate args, then decide whether to execute
            ...
        await session.send_tool_response(function_responses=function_responses)

A raw WebSocket frame is one JSON object. The server message does not emit a separate messageType; the field sits at the top:

{
  "toolCall": {
    "functionCalls": [
      {
        "id": "call_light_01",
        "name": "set_light",
        "args": {
          "room": "living_room",
          "on": true
        }
      }
    ]
  }
}

Parse order is fixed: confirm the top level is toolCall → take the array → read three fields per item. Missing functionCalls, not an array, or an item missing id / name: treat the whole frame as bad. Do not guess. One prompt can emit several calls in parallel, so you must iterate. Do not take only [0].

Read a FunctionCall field by field

The reference FunctionCall has three jobs:

  • id: the key for this reach. Return, cancel, and logs all use it. Do not rewrite it. Do not mint another.
  • name: must land in a functionDeclarations you already sent. Unknown names return an error. No fuzzy match.
  • args: an object. That is the contract. Empty means {}. Do not treat null as “no parameters.”

Declarations still live on session config.tools. The official minimum sample has only name. In production, add description and parameters at least:

{
  "functionDeclarations": [
    {
      "name": "set_light",
      "description": "Set one allowlisted room light. Use when the user asks to turn a light on or off.",
      "behavior": "NON_BLOCKING",
      "parameters": {
        "type": "OBJECT",
        "properties": {
          "room": {
            "type": "STRING",
            "enum": ["living_room", "kitchen", "bedroom"],
            "description": "Allowlisted room id. Do not invent rooms."
          },
          "on": { "type": "BOOLEAN" }
        },
        "required": ["room", "on"]
      }
    }
  ]
}

3.8 Live defaults to NON_BLOCKING. If the next sentence must wait, set that tool to BLOCKING. The extended-thinking variant allows async only. Permission shape matches a text Agent; see Limit tool permission with Schema.

Validate args before you execute

Voice makes the second layer more dangerous. A slip, an accent, a room name heard as another enum value — the model can still emit args that look legal. There is no reply Schema to catch it. The parameter check before execute is the only hard gate.

Fold the declaration into a Schema you can run on the workbench:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "additionalProperties": false,
  "required": ["room", "on"],
  "properties": {
    "room": {
      "type": "string",
      "enum": ["living_room", "kitchen", "bedroom"]
    },
    "on": { "type": "boolean" }
  }
}

Three steps, then the executor:

  1. Type. args must be an object. If you received a string, JSON.parse / json.loads once only. Fail or not an object: return an error.
  2. Contract. required, enum, additionalProperties: false. An extra extra_cmd is a refuse.
  3. Name. name must equal a key in your declaration table. Do not guess living_room from a “similar room.”

Pin the shapes that must fail. They should go red on the workbench:

{
  "ok": { "room": "living_room", "on": true },
  "unknown_room": { "room": "garage", "on": true },
  "extra_field": { "room": "living_room", "on": true, "extra_cmd": "unlock" },
  "wrong_type": { "room": "living_room", "on": "开" },
  "args_as_string": "{\"room\":\"living_room\",\"on\":true}"
}

Only ok enters set_light. garage, extra_cmd, "开", and the still-string payload all return an error object. Do not “try anyway.” Derive a contract from samples: How to generate a Schema.

Build FunctionResponse

A WebSocket return must sit inside toolResponse. Each FunctionResponse matches the original FunctionCall by id:

{
  "toolResponse": {
    "functionResponses": [
      {
        "id": "call_light_01",
        "name": "set_light",
        "response": {
          "result": {
            "room": "living_room",
            "on": true,
            "applied": true
          },
          "scheduling": "INTERRUPT"
        }
      }
    ]
  }
}

SDK equivalent:

function_response = types.FunctionResponse(
    id=fc.id,
    name=fc.name,
    response={
        "result": {"room": "living_room", "on": True, "applied": True},
        "scheduling": "INTERRUPT",
    },
)
await session.send_tool_response(function_responses=[function_response])

On an async return, scheduling tells the model how to use the result:

  • INTERRUPT: cut the current speech and say the result now.
  • WHEN_IDLE: wait until the current stretch finishes.
  • SILENT: store it; use it later.

Samples put scheduling inside response, next to result. That is a scheduling hint, not a business field. The executor reads result only. A validation failure still needs a return. Do not stay silent:

{
  "id": "call_light_01",
  "name": "set_light",
  "response": {
    "error": {
      "code": "invalid_args",
      "message": "room is not in the allowlist"
    },
    "scheduling": "INTERRUPT"
  }
}

The error object carries the same id / name. Then the model can say “that room is not on the list” instead of pretending the light is on. Close the business result yourself: which fields, whether applied exists, pin it with the same Schema. Do not let scheduling leak into the executor.

Parallel calls and cancel

From one prompt the model can emit several functionCalls, and later more BidiGenerateContentToolCall messages. Client job:

  1. Walk the whole array. Do not handle only the first item.
  2. Validate each args on its own. One failure does not drop the other returns, but the failed item must come back as an error.
  3. The return array length matches the calls you received. Drop one and the model cannot pair.
{
  "toolCall": {
    "functionCalls": [
      {
        "id": "call_light_01",
        "name": "set_light",
        "args": { "room": "living_room", "on": true }
      },
      {
        "id": "call_light_02",
        "name": "set_light",
        "args": { "room": "kitchen", "on": false }
      }
    ]
  }
}

When the user interrupts the current turn, the server may send toolCallCancellation:

{
  "toolCallCancellation": {
    "ids": ["call_light_01"]
  }
}

Do not execute a matching id. If you already reached, the docs allow an attempt to undo. This appears only when the client interrupts a server turn. Do not fold it with a plain interrupted audio signal into one “cancel everything.”

If an async tool needs a background task, the official cloud sample parks a slow lookup in asyncio.create_task and lets the receive() loop keep listening. When it finishes, return with the original id; scheduling is often WHEN_IDLE. Do not block five seconds inside receive().

Common parse mistakes

  • Treat the whole WebSocket frame as args. args lives at toolCall.functionCalls[i].args. Feed the envelope into the executor and the Schema is too wide or fails outright.
  • Take only functionCalls[0]. Parallel lights drop the second room.
  • Mint a new id on return. Live pairs by id. A new id does not match the original reach.
  • Use string args as an object. Check the type first. A string may be parsed once.
  • Fail validation and send nothing. The model waits or talks nonsense. An error is still a FunctionResponse.
  • Write scheduling into the business store. That hint is for the model, not a light state.
  • Ignore toolCallCancellation. The user already interrupted; the light still follows the old command.

These mistakes hide more easily in an audio loop: the user hears “okay,” the log holds another id. Turn the objects red and green on the workbench first, then hang them on a Live session.

How to accept it in JSON Toolbox

  1. Paste the toolCall and toolResponse above into JSON format. Confirm they parse, and every array item has an id.
  2. Extract args. Use JSON → Schema for a core contract, then add enum, required, and additionalProperties.
  3. Run the same fields through Function Calling. If another host will reuse them, wrap MCP Tool.
  4. Drop garage, extra_cmd, and "开" into Schema validator and confirm they fail. When you need a reply shape, use Structured Output — remember a Live session does not consume that reply contract.

Everything stays in the browser. Nothing is uploaded. First have an object that refuses bad args and echoes the same id, then hang the function on gemini-3.8-live.

FAQ

Are SDK tool_call and WebSocket toolCall the same object?

Same contract, different casing. WebSocket uses camelCase; the Python SDK uses snake_case. On return the SDK calls send_tool_response; a raw frame sends toolResponse.

Sometimes args is a string, sometimes an object. What then?

The contract says object. Check the type first. A string may be parsed once. Fail or not an object: return an error. Do not pass the string into the executor.

What happens if the response drops the id?

Live pairs by id. Drop it, mistype it, or answer only half, and the model cannot pair this reach. Copy name as well. Do not mint a new id.

If toolCallCancellation arrives, should I still execute?

No. Stop the matching ids[]. Side effects may be undone. It appears only when the client interrupts a server turn.

Does scheduling belong inside the business result?

No. It sits next to result as a hint for the model. The executor reads only the business object.

Summary

The Gemini 3.8 Live tool loop does not auto-execute. You parse three objects: toolCall.functionCalls, args, toolResponse.functionResponses. Few fields: id, name, one JSON object. The danger is voice making args look legal. Refuse garage and extra_cmd with Schema before execute. Echo the same id on return. Do not reach for a cancelled id. The next step is not more room tools. It is finishing these three objects on the workbench.