CrewAICrewAI 1.15 · Python 3.10 to 3.13
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
35 small wins to finish your pathNext lesson →

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.

Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
tools.py
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."
shop_llm.py
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

python
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

python
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

Example
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.")))

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 guardrailA tool hook
ChecksA finished replyA tool call before it runs
On failureSends the reason back to retryBlocks the call
Used here forNo card number in a replyApproving 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.
Watch out
A guardrail reads 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.
Try it yourself
  • 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.