Desk state and a reply guardrail
The desk state is a typed object that travels through the flow, and the guardrail is a check every reply passes before it can leave. Together they open the finished desk.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The last lessons swapped stand-ins for real backends. Now the desk is assembled into one file, desk.py, which the next lessons run. It opens with the state that moves from step to step and the guardrail that guards the reply.
View the code here
from crewai.tools import tool
ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}
@tool
def lookup_order(order_id: str) -> str:
"""Look up an order's shipping status by its id, such as A17."""
status = ORDERS.get(order_id)
return f"{order_id} {status}." if status else f"{order_id} is not an order we have."
import json
import os
import re
from crewai import BaseLLM
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
os.environ["CREWAI_TRACING_ENABLED"] = "false"
os.environ["CREWAI_DISABLE_VERSION_CHECK"] = "true"
class ShopLLM(BaseLLM):
script: list = []
def supports_function_calling(self):
return True
def call(self, messages, tools=None, **kwargs):
if isinstance(messages, str):
messages = [{"role": "user", "content": messages}]
if self.script:
return self.script.pop(0)
return self.decide(messages, tools or [])
def decide(self, messages, tools):
last = messages[-1]
if last["role"] == "tool":
return last["content"]
text = last["content"]
orders = re.findall(r"\b[A-Z]\d+\b", text)
want_refund = "refund" in text.lower()
chosen = None
for t in tools:
fn = t["function"]
label = (fn["name"] + " " + (fn.get("description") or "")).lower()
is_refund = "refund" in label
if want_refund and is_refund:
chosen = fn["name"]
break
if not want_refund and not is_refund and ("look up" in label or "status" in label):
chosen = fn["name"]
break
if orders and chosen:
args = json.dumps({"order_id": orders[0]})
return [{"id": f"call_{orders[0]}", "type": "function",
"function": {"name": chosen, "arguments": args}}]
if "working with:" in text:
context = text.split("working with:")[1].strip().split("\n\n")[0]
return f"Dear customer, {context}"
if orders:
return f"I have no way to look up {orders[0]} yet."
return "Hello. Which order is this about?"
The imports and the ticket state
import re
from crewai import Agent, Crew, Task
from crewai.flow import Flow, HumanFeedbackPending, HumanFeedbackProvider, human_feedback
from crewai.flow.flow import listen, router, start
from pydantic import BaseModel
from shop_llm import ShopLLM
from tools import lookup_order
class Ticket(BaseModel):
message: str = ""
order_id: str = ""
reply: str = ""Ticket is the flow's state: the customer's message, the order id once it is found, and the reply as it is written. Every step reads and writes those three fields.
The guardrail and the manager's inbox
def no_card_numbers(output):
if re.search(r"\d{4} ?\d{4} ?\d{4} ?\d{4}", output.raw):
return (False, "Remove the card number. Never repeat one to a customer.")
return (True, output.raw)
class ManagerInbox(HumanFeedbackProvider):
def request_feedback(self, context, flow):
print("to the manager:", context.method_output)
raise HumanFeedbackPending(context=context)no_card_numbers returns the text when it is safe and a sentence for the agent to fix when it is not. ManagerInbox is what pauses a refund and shows it to a person, a class you will wire in the next lesson.
Running the guardrail on two replies
print(no_card_numbers(Output("Your refund goes back to card 4111 1111 1111 1111.")))
print(no_card_numbers(Output("Dear customer, order A17 shipped on 3 March.")))(False, 'Remove the card number. Never repeat one to a customer.') (True, 'Dear customer, order A17 shipped on 3 March.')
Reading the two results
- A reply with a card number is refused, and the second value is the reason the agent will read on its next attempt.
- A clean reply comes back with True and the text unchanged, so the task keeps it.
- The guardrail is a plain function, not a model call, so it holds whatever the model writes.
A guardrail against a hook
Both stop something unsafe, but at different moments.
| A task guardrail | A tool hook | |
|---|---|---|
| Checks | A finished reply | A tool call before it runs |
| On failure | Sends the reason back to retry | Blocks the call |
| Used here for | No card number in a reply | Approving a refund |
Where a reply guardrail earns its place
- Keeping card numbers, passwords or secrets out of a customer reply.
- Forcing a format, such as a reply that must name the order.
- Any rule that must hold no matter what the model wrote.
output.raw, so a return that is not a task output with a .raw raises before your check runs. Return the pair the task expects, (True, text) or (False, reason), and return only that pair.Related
- Previous: Integrations: from stand-in to production
- Next: Desk crew inside a flow
- Reference: CrewAI docs, Flows
- Add a rule that refuses a reply containing an email address.
- Return a different reason and watch what the agent is told.
- Print
Ticket().model_dump()to see the fields a new ticket starts with.
Little by little, you're building something great.