Server vs. Client Execution
On this page
Lesson updated November 3, 2026 based on
mistralaiSDK 3.7.0 and the Mistral Handoffs documentation: anagent.handoffentry identifies the target agent vianext_agent_id(notto_agent), andconversations.appenddoes not accept anagent_idparameter—older code would fail. Target agent resumption here occurs via a new conversation, to which you pass the context. Sources: Handoffs · client-python.
Two Ways to Handle Handoffs
When an agent transfers control to another agent, the Mistral API offers two execution modes: server (automatic) and client (manual). The setting is a single word, but it determines who actually drives the workflow. In server mode, you delegate the entire choreography to Mistral and receive the result. In client mode, you regain control between each step. Everything else—the agents, their tools, their handoff targets—remains exactly the same.
Server Mode, the Default
response = client.beta.conversations.start(
agent_id=router_agent.id,
inputs="Analyze and calculate market trends.",
handoff_execution="server" # Default — can be omitted
)
The flow is fully managed: the router receives the request, decides to transfer to the research agent, and Mistral executes the transfer; the research agent does its work then transfers to the calculator, and Mistral executes that as well; the calculator produces the result. You receive the final response, accompanied by all intermediate outputs—so you see what happened, but after the fact.
This mode has three qualities that explain why it is the default. It is simple, since you write no control logic on your side. It is fast, because the entire chain executes at Mistral without network round-trips at each relay. It is reliable, as error handling and timeouts between agents are handled for you.
Its limitations are the exact counterpart. You have no intermediate control: impossible to validate or modify a handoff while it occurs. Cost is opaque, in the sense that all agents in the chain execute without you being able to interrupt mid-flow a request heading in an unnecessary direction. And you cannot insert any custom logic between steps—no fine-grained logging, no validation, no business decisions.
Client Mode, When You Want Control
response = client.beta.conversations.start(
agent_id=router_agent.id,
inputs="Analyze the cloud market.",
handoff_execution="client"
)
The router receives the request and decides to transfer, exactly as before. But instead of the transfer executing, you receive an agent.handoff event and the chain stops there. It is up to you to restart it by calling the target agent—which leaves you the latitude to validate, log, enrich the context, or simply refuse.
response = client.beta.conversations.start(
agent_id=router_agent.id,
inputs="Search for the latest tech news.",
handoff_execution="client"
)
for entry in response.outputs:
if entry.type == "agent.handoff":
target_agent_id = entry.next_agent_id
print(f"Handoff requested to: {target_agent_id}")
# Validate, then hand over to the target agent, with useful context
next_response = client.beta.conversations.start(
agent_id=target_agent_id,
inputs="Search for the latest tech news.",
)
# Process the target agent's response
for next_entry in next_response.outputs:
print(f"{next_entry.type}: {next_entry.content}")
One point deserves attention: Mistral’s documentation describes client mode—control returns to you at transfer time—but provides no method to have the target agent resume the same conversation, and conversations.append does not accept an agent_id parameter. The example therefore starts a conversation with the target agent, to whom you explicitly transmit what they need: the original request, and if applicable a summary of what has already been done. This is also an opportunity to transmit only what they must see.
The snippet above handles only a single relay. A real chain links several, which calls for a loop.
def execute_with_control(router_id, query):
"""Executes a multi-agent workflow with control at each step."""
response = client.beta.conversations.start(
agent_id=router_id,
inputs=query,
handoff_execution="client"
)
conversation_id = response.conversation_id
results = []
while True:
handoff_found = False
for entry in response.outputs:
if entry.type == "agent.handoff":
handoff_found = True
target = entry.next_agent_id
print(f"[LOG] Handoff to {target}")
# Here you can add: validation, logging, metrics
response = client.beta.conversations.start(
agent_id=target,
inputs=query, # the original request, enriched if needed
handoff_execution="client",
)
conversation_id = response.conversation_id
break
elif entry.type == "message.output":
results.append(entry.content)
if not handoff_found:
break
return results
The structure is that of a pump: as long as a handoff appears in the outputs, you process it and restart; as soon as a loop iteration finds none, the chain is finished and you exit with the accumulated messages. The comment in the middle marks the location that justifies this entire mode: it is there, and only there, that your code interposes itself between two agents.
Choosing Between the Two
Server mode covers standard production. If your workflow is predictable, if you trust the agents you have written, or if you are prototyping and want to see results quickly, full automation is the right choice—and writing a control loop would be wasted effort.
Client mode is justified as soon as an external requirement enters the chain. Human validation, when an operator must approve a transfer to an agent that triggers sensitive actions. A need for logging and auditing, when compliance requires tracing each step with your own identifiers and your own timestamps. Conditional routing, when the decision depends on business rules the model cannot know—customer status, time of day, a quota. A cap, or rate limiting, when you want to bound the number of agents executed for a request. A fallback strategy, finally, when one agent’s failure must trigger something other than an error surfaced to the user.
Here is what the first of these situations looks like, reduced to its skeleton.
def workflow_with_validation(router_id, query):
response = client.beta.conversations.start(
agent_id=router_id,
inputs=query,
handoff_execution="client"
)
for entry in response.outputs:
if entry.type == "agent.handoff":
target = entry.next_agent_id
approval = input(f"Authorize transfer to {target}? (y/n) ")
if approval.lower() == "y":
response = client.beta.conversations.start(
agent_id=target,
inputs=query,
)
else:
print("Transfer refused.")
break
The console input serves as a demonstration; in an application, it will be replaced by a notification to an operator and by workflow resumption when the response arrives. The principle remains the same: the transfer occurs only if your code triggers it.
The right approach is to start in server mode and switch only the day a specific constraint requires it. Switching to client mode “just in case” makes you pay immediately for the complexity of a loop and network round-trips, for control you are not yet using.
Key Takeaways
- Server mode = fully automatic, simple and fast, no intermediate control
- Client mode = you receive handoff events and decide what to do
- Client mode enables validation, logging, conditional routing, and error handling
- For most cases, server mode is sufficient—switch to client mode when you need control
- Both modes support the same agent chains; only the control differs