Parallel Tool Calls in the Mistral API
9 min read · updated August 11, 2026
A Mistral response can contain more than one entry in tool_calls. Handling that correctly is almost entirely a question of ids: each call carries one, each result must quote it back, and Mistral validates the format of the value more strictly than most people expect.
The shape of a multi-call response
Send a request with two tools defined and a prompt that needs both, and the assistant message comes back with content empty or null and a tool_calls array holding one object per requested call:
{
"id": "cmpl-8f3a1c...",
"object": "chat.completion",
"model": "mistral-large-latest",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "AbCd12EfG",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Paris\"}"
}
},
{
"id": "Hi34JkLmN",
"type": "function",
"function": {
"name": "get_time",
"arguments": "{\"city\": \"Paris\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}Three things in there decide how your code is written. finish_reason is tool_calls, not stop — that is the flag to branch on, and branching on “is content empty?” instead is the bug that shows up the first time a model returns text and a call together. function.arguments is a string containing JSON, not a JSON object, so it needs parsing before use. And each entry has its own id.
The id is the whole protocol
There is no positional matching. The model does not promise that the first result you append corresponds to the first call it made, and you should not write code that assumes it. The id on the call is the only link between a request for a tool and the result of running it.
Mistral’s tool call ids have a documented shape that is unusually strict: the value must be exactly nine characters drawn from a-z, A-Z and 0-9. This matters because it is the one thing you might be tempted to generate yourself — when replaying a conversation, when synthesising a tool call in a test fixture, or when translating a transcript that came from another provider whose ids look like call_abc123def456. Any of those produces a value Mistral rejects.
# A tool_call_id Mistral accepts.
import random, string
def mistral_tool_call_id() -> str:
alphabet = string.ascii_letters + string.digits
return "".join(random.choices(alphabet, k=9))Matching results back
Run both tools, then append one message per call with role tool, quoting the id in tool_call_id. The assistant message containing the calls must stay in the history — the tool results are meaningless without it.
import json, os
from mistralai import Mistral
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
messages = [{"role": "user", "content": "Weather and local time in Paris?"}]
first = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools,
tool_choice="auto",
)
assistant = first.choices[0].message
messages.append(assistant) # keep the calls in history
for call in assistant.tool_calls or []:
args = json.loads(call.function.arguments) # arguments is a JSON string
result = REGISTRY[call.function.name](**args)
messages.append({
"role": "tool",
"name": call.function.name,
"tool_call_id": call.id, # not the index
"content": json.dumps(result),
})
second = client.chat.complete(
model="mistral-large-latest",
messages=messages,
tools=tools,
)
print(second.choices[0].message.content)Note that content on a tool message is a string. If your tool returns a dict, serialise it. Passing an object where a string is expected fails validation before the request reaches a model, which at least gives you a fast error rather than a confused answer.
The errors you will hit
- A tool result with no matching call. Appending a
toolmessage whosetool_call_iddoes not appear in the immediately preceding assistant message is a malformed conversation. This happens when you truncate history to fit the context window and drop the assistant turn while keeping the results. - A call with no result. The inverse, and the more common one: the model asked for three tools, one of yours threw, and you appended two results. Append a result for every call, even if the content is
{"error": "timeout"}. The model handles a reported failure far better than a missing turn. - An id that fails validation. Covered above. Round-tripping a conversation through a store that trims or re-generates ids is the usual cause.
- Unparseable arguments. Rare with a well-specified schema, but the arguments string is model output and can be malformed. Wrap the
json.loadsand feed the parse failure back as a tool result rather than crashing the loop.
When calls are not parallel
Parallel calling is only available when the calls are independent. If the second tool needs the first tool’s output as an argument, the model cannot batch them — it has no value to put in the argument yet — and you will see one call, then a second round trip after you return the result. That is correct behaviour, not a degradation, and it is why an agent loop must be a loop rather than a single call-and-respond.
There is a second, quieter reason for a single call where you expected two: your schema made batching impossible. A tool declared with a scalar city parameter can only be asked about one city per call, so “the weather in Paris, Lyon and Marseille” costs three entries in tool_calls. Declare the parameter as an array of strings and the same question becomes one call with three values — fewer tokens spent on the call block, one round trip through your dispatcher, and one result to append instead of three. Where a tool is naturally plural, saying so in the schema is worth more than any amount of prompt tuning.
A note on execution order while you are here. The calls in the array are independent by construction, which means you may run them concurrently — a thread pool, asyncio.gather, whatever your runtime offers. The model does not care in which order the results are appended, because matching is by id, so the only constraint is that every call has a result before you send the follow-up request. For a loop that fans out to three network-bound tools, that is the difference between three sequential timeouts and one.
If you are streaming, the calls arrive incrementally rather than whole. Each chunk carries a delta that may contain a partial tool_calls entry, with the arguments string built up fragment by fragment across chunks and an index identifying which call in the array the fragment belongs to. You cannot parse the arguments until the stream completes; accumulate per index, and only call json.loads once the finish reason arrives. Attempting to parse a partial arguments string is a reliable way to produce an error that looks like the model emitted invalid JSON when it did not.
You will also see a single call where you expected several simply because the model judged one to be enough. Forcing the issue is a job for tool_choice, covered in Mistral’s tool_choice options, and the case where it makes no call at all is a separate behaviour with its own causes.