Function Calling in the xAI API
9 min read · updated August 11, 2026
xAI documents function calling twice, in two different request shapes, and the field names do not match between them. Getting a tool call to work is mostly a matter of knowing which of the two you are in.
There are two documented shapes
The xAI inference API exposes an OpenAI-compatible chat completions endpoint at /v1/chat/completions and a Responses-style endpoint at /v1/responses. Both accept tools. They do not accept them in the same form.
In the chat completions shape, a tool is an object with type: “function” and a nested function object holding the name, description and schema — the shape any existing OpenAI client already emits. In the Responses shape documented on xAI’s function calling guide, the same three fields are flattened onto the tool object itself:
{
"model": "grok-4.5",
"input": [
{ "role": "user", "content": "What is the weather in Amsterdam?" }
],
"tools": [
{
"type": "function",
"name": "get_weather",
"description": "Current conditions for a city.",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name." },
"units": { "type": "string", "enum": ["c", "f"] }
},
"required": ["city"],
"additionalProperties": false
}
}
]
}The same tool, sent to /v1/chat/completions, has to be wrapped:
{
"model": "grok-4.5",
"messages": [
{ "role": "user", "content": "What is the weather in Amsterdam?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Current conditions for a city.",
"parameters": { "type": "object", "properties": { "...": {} } }
}
}
]
}This is the single most common cause of a tool that the model appears to ignore: the definition was written for one endpoint and sent to the other, and the tool either fails validation or is not visible to the model. Fix the shape before you rewrite the description.
Defining a tool
Whichever shape you are in, the three fields carry the same weight. name is what comes back to you and what you dispatch on. description is the only thing the model has to decide whether this tool is the right one — it is prompt text, not documentation, and it is read on every request. parameters is a JSON Schema object and is the part with a hard rule attached, below.
xAI’s API reference documents a ceiling of 128 function tools per request. That is a generous limit and a bad target: every tool definition is prompt tokens on every call, and it is charged against the same input rate as your actual content. A large tool list is a persistent tax on a workload, which is one reason the 200k pricing step matters more to agent loops than to chat.
The root schema rule that returns 400
xAI’s function calling guide states the constraint directly: the root parameters schema must be an object type, or a union of object types expressed with oneOf or anyOf. A scalar at the root, an array at the root, or a union with a non-object branch is rejected with a 400 rather than tolerated.
# rejected — root is an array
"parameters": { "type": "array", "items": { "type": "string" } }
# rejected — union with a scalar branch
"parameters": { "anyOf": [ { "type": "object" }, { "type": "string" } ] }
# accepted — wrap it
"parameters": {
"type": "object",
"properties": { "ids": { "type": "array", "items": { "type": "string" } } },
"required": ["ids"]
}The rule is not arbitrary. Arguments come back as a JSON object keyed by parameter name, so a root that is not an object has nowhere for the keys to live. If you are porting tool definitions from a provider that is laxer about this, the array-at-root case is the one that will bite.
Returning the result
When the model decides to call the tool, the response carries a tool call with a name, an arguments string and a call id. The arguments arrive as a JSON string, not as a parsed object, in both shapes — parse it, and parse it defensively, because a schema constrains structure and does not guarantee the values are sensible.
You then run the function yourself and send the outcome back. In the Responses shape that is a tool_result entry appended to input; in the chat completions shape it is a message with role: “tool” carrying the matching tool_call_id. The model never executes anything. The loop — send, receive a call, execute, append, send again — is entirely yours, and it terminates when a turn comes back with content and no tool call.
# chat completions: the tool turn, then your reply
{ "role": "assistant", "tool_calls": [
{ "id": "call_01", "type": "function",
"function": { "name": "get_weather",
"arguments": "{\"city\":\"Amsterdam\",\"units\":\"c\"}" } } ] }
{ "role": "tool", "tool_call_id": "call_01",
"content": "{\"temp_c\":19,\"condition\":\"overcast\"}" }Two contract details in that exchange are load-bearing. The tool_call_id on your reply must match the id the model issued; with parallel_tool_calls on you may receive several calls in one turn, and the ids are the only thing pairing each result with its request. Return them all before the next assistant turn — a turn that answers two of three outstanding calls is a malformed conversation, not a partial one. And the content of a tool message is a string. If your handler returns a dict, serialise it; passing an object where a string is expected is a client-side type error that reads, confusingly, like a model failure.
Errors belong in the tool result too, not in an exception. If the function fails, return a short JSON object saying so — a {"error": "no such city"} is information the model can act on, and it will usually ask the user or try a different argument. Raising instead means the model never learns the call failed, and the loop either stalls or retries the same call.
tool_choice and parallel calls
tool_choice takes the OpenAI values — a string such as auto or required, or an object naming one function to force. Forcing a specific tool is the reliable way to get structured arguments out of a model that keeps answering in prose, and it is a real alternative to schema-constrained output when what you want is an argument list rather than a document.
parallel_tool_calls is a boolean, and setting it to false restricts the model to at most one tool call per turn. Leave it on when the calls are independent reads; turn it off when your handlers share state, mutate anything, or must be ordered. The same reasoning applies across providers — the Anthropic equivalent exists for identical reasons.
Tool calls do not stream
This is the xAI-specific behaviour worth writing down. Its function calling guide states that with streaming, the function call is returned whole in a single chunk rather than streamed across chunks.
That is the opposite of the OpenAI convention, where tool_calls[].function.arguments arrives as a series of string fragments that the client concatenates. Code written against the fragment model — accumulate into a buffer, parse at the end — still works here, because a single chunk is a degenerate case of many. Code written the other way round does not: a handler that assumes it can parse each Grok chunk as complete JSON will break the moment it is pointed at a provider that fragments. Write the accumulating version and it is correct on both.
That “port in the safe direction” rule covers the rest of the compatibility surface too, and the edges are worth knowing before you meet them. logit_bias is documented as unsupported. logprobs and top_logprobs are documented as silently ignored by grok-4.20 and later — accepted, with no effect and no error, which is the worst failure mode a parameter can have. presence_penalty, frequency_penalty and stop are rejected outright on reasoning models. A client ported wholesale from another provider will send several of these without anybody noticing, so audit the parameters your SDK actually emits rather than the ones you think you set. The rest of the stream shape is covered in the streaming response format.