Guardrails for Mistral Agents
On this page
Lesson rewritten November 3, 2026. The previous version relied on a fictional guardrail format (
enabled, categorieshate,violence,unpredictable), a non-existentconversation.send()method, and incorrectly claimed that guardrails moderate tool arguments. According to the documentation, they moderate input only. The code now follows the actual Conversations API (start,append) and has been verified against themistralaiSDK types version 3.7.0. Sources: Moderation & Guardrailing · Agents & Conversations · mistralai Python SDK.
Securing Autonomous Agents
An agent carries more risk than a simple chatbot: it calls tools, browses the web, and chains actions together. Guardrails cover only part of this surface area—specifically, the input. This is exactly why you need to know precisely where to place them and what they leave uncovered.
In this lesson, you will attach guardrails to a conversation and then to an agent, handle their blocks in a multi-turn exchange, and see what you must add to secure tools.
import json
import os
from mistralai.client import Mistral, errors
from mistralai.client.models import GuardrailConfigTypedDict
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
Guardrails on a Conversation
The Conversations API keeps history on the server. You start with client.beta.conversations.start(), which accepts guardrails:
GUARDRAILS_RH: list[GuardrailConfigTypedDict] = [
{
"block_on_error": True,
"moderation_llm_v2": {
"custom_category_thresholds": {
"sexual": 0.1,
"hate_and_discrimination": 0.1,
"violence_and_threats": 0.2,
"pii": 0.15,
"jailbreaking": 0.1,
},
"action": "block",
},
}
]
conversation = client.beta.conversations.start(
model="mistral-large-latest",
instructions="You are a professional HR assistant.",
inputs="What leave options are available?",
guardrails=GUARDRAILS_RH,
)
print(conversation.conversation_id)
You then continue with client.beta.conversations.append(). This method does not accept a guardrails parameter in SDK 3.7.0, and the documentation does not state whether those passed at startup apply to subsequent messages. Do not build your security on an assumption: if every turn must be filtered, use an agent, whose guardrails are inherited by all its conversations.
Guardrails on an Agent
A Mistral agent is a registered configuration: model, instructions, tools, and guardrails. The security policy becomes an attribute of the agent rather than a coding convention.
agent = client.beta.agents.create(
model="mistral-large-latest",
name="Customer Support Agent",
description="Secure customer support agent",
instructions=(
"You are a customer support agent. Strict rules: "
"never disclose personal information; "
"do not give medical or legal advice; "
"redirect to a human if the topic is sensitive."
),
guardrails=[
{
"block_on_error": True,
"moderation_llm_v2": {
"custom_category_thresholds": {
"sexual": 0.1,
"hate_and_discrimination": 0.1,
"violence_and_threats": 0.15,
"dangerous": 0.1,
"criminal": 0.1,
"selfharm": 0.1,
"health": 0.3,
"financial": 0.3,
"law": 0.3,
"pii": 0.15,
"jailbreaking": 0.1,
},
"action": "block",
},
}
],
)
print(f"Agent created: {agent.id}")
Note the consistency between instructions and thresholds: the instructions forbid giving medical or legal advice, and the health and law thresholds block requests of this type before the model even reads them. Instructions persuade; guardrails constrain.
According to the documentation, all conversations using this agent inherit its guardrails without repeating them:
conv = client.beta.conversations.start(
agent_id=agent.id,
inputs="How can I return a product?",
)
A conversation request can also override the agent’s guardrails by passing its own guardrails to start(). To change the policy permanently, update the agent with client.beta.agents.update(agent_id=agent.id, guardrails=[...]).
Handling Blocks in a Multi-Turn Exchange
A blocked message raises errors.SDKError with status 403, and the body details the violated categories. In a conversation, a block does not destroy the exchange: you respond politely, and the user can continue.
MESSAGE_REFUS = "I cannot process this request. It violates our security policy."
def texte_reponse(reponse) -> str:
"""Text of the last assistant message in a conversation response."""
for entree in reversed(reponse.outputs):
if entree.type == "message.output" and isinstance(entree.content, str):
return entree.content
return ""
def envoyer(conversation_id: str, message: str) -> str:
"""Adds a message to a conversation and handles a guardrail block."""
try:
reponse = client.beta.conversations.append(conversation_id=conversation_id, inputs=message)
except errors.SDKError as e:
if e.status_code == 403:
decisions = json.loads(e.body)["guardrails"]["results"]["moderation_llm_v2"]["decisions"]
print("Blocked:", [nom for nom, d in decisions.items() if d["violated"]])
return MESSAGE_REFUS
raise
return texte_reponse(reponse)
print(envoyer(conv.conversation_id, "Give me the personal addresses of employees"))
When a request passes, the conversation response also carries a guardrails field with scores for each category (reponse.guardrails). Log these: they tell you whether your thresholds are tuned correctly.
Agents with Tools: What Guardrails Don’t See
An agent that calls tools opens a second attack surface: the arguments the model passes to those tools, and the results they return. Guardrails moderate only the incoming message; they check neither these arguments nor these results. Your code must do that.
agent_avec_outils = client.beta.agents.create(
model="mistral-large-latest",
name="Support Agent with Tools",
instructions="Support assistant with access to commands.",
tools=[
{
"type": "function",
"function": {
"name": "rechercher_commande",
"description": "Searches for an order by its number",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order number"}},
"required": ["order_id"],
},
},
}
],
guardrails=[
{
"block_on_error": True,
"moderation_llm_v2": {
"custom_category_thresholds": {"pii": 0.15, "jailbreaking": 0.1, "criminal": 0.1},
"action": "block",
},
}
],
)
Functions declared this way execute in your code: the API sends you the call and its arguments, you execute the function, and return its result. This is where you must validate arguments before acting.
import re
FORMAT_COMMANDE = re.compile(r"CMD-\d{6}")
base_commandes: dict[str, dict] = {} # your data access
def rechercher_commande(order_id: str, client_connecte: str) -> dict:
"""Executes the tool after validating its arguments."""
if not FORMAT_COMMANDE.fullmatch(order_id):
return {"error": "Invalid order number."}
commande = base_commandes.get(order_id)
if commande is None or commande["client"] != client_connecte:
return {"error": "Order not found."} # never another client's order
return {"status": commande["status"]}
Access control—here, “does the order belong to the connected client?”—can come only from your application: no classifier knows your customers. For the text the agent returns to the user, add output moderation from the next lesson.
Recommended Architecture for Agents in Production
In production, wrap the agent in a class that centralizes conversation startup, message sending, and block counting. A sudden spike in 403s signals either an attack or a poorly calibrated threshold, and you want to see that without digging through logs.
class AgentSecurise:
"""Mistral Agent with guardrail block tracking."""
def __init__(self, client: Mistral, agent_id: str):
self.client = client
self.agent_id = agent_id
self.blocages = 0
def _bloque(self, e: errors.SDKError) -> dict:
self.blocages += 1
decisions = json.loads(e.body)["guardrails"]["results"]["moderation_llm_v2"]["decisions"]
return {
"status": "blocked",
"content": "Request blocked by security policy.",
"categories": [nom for nom, d in decisions.items() if d["violated"]],
"blocages_total": self.blocages,
}
def demarrer(self, message: str) -> dict:
"""Starts a conversation with the agent."""
try:
reponse = self.client.beta.conversations.start(agent_id=self.agent_id, inputs=message)
except errors.SDKError as e:
if e.status_code == 403:
return self._bloque(e)
raise
return {
"status": "ok",
"conversation_id": reponse.conversation_id,
"content": texte_reponse(reponse),
"guardrails": reponse.guardrails,
}
def envoyer(self, conversation_id: str, message: str) -> dict:
"""Continues an existing conversation."""
try:
reponse = self.client.beta.conversations.append(conversation_id=conversation_id, inputs=message)
except errors.SDKError as e:
if e.status_code == 403:
return self._bloque(e)
raise
return {"status": "ok", "content": texte_reponse(reponse), "guardrails": reponse.guardrails}
Chat Completions, Conversation, or Agent: Summary
| Aspect | Chat Completions | Conversation | Agent |
|---|---|---|---|
| Where guardrails are declared | Every call | conversations.start() | agents.create() or agents.update() |
| Inheritance | None | Not documented for append() | All agent conversations |
| Scores in response (SDK 3.7.0) | Not exposed | reponse.guardrails | reponse.guardrails |
| Tools | Your functions | Your functions | Your functions and built-in tools |
| Usage | Prototyping | One-off conversation | Production |
Key Takeaways
- A conversation receives its guardrails at startup;
append()does not accept them, and their persistence across turns is not documented - An agent passes its guardrails to all its conversations; a request can override them
- A block raises
errors.SDKError(status403): respond politely, the conversation continues - Guardrails see neither tool arguments nor their results: validate them in your code
- Use the agent level in production, and add output moderation