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 crew inside a flow

The desk crew is two agents that answer an order question, and the flow around it decides which tickets reach the crew and what happens to the rest.

Last updated: 28 Sep, 2026 · CrewAI 1.15

The last lesson set up the state and the guardrail. This one adds the crew and the flow that routes to it, finishing desk.py.

A crew of a clerk and a writer

python
def answer_crew():
    clerk = Agent(role="Order clerk", goal="Find the status of customers' orders",
                  backstory="You can look up any order.", llm=ShopLLM(model="shop"),
                  tools=[lookup_order], verbose=False)
    writer = Agent(role="Reply writer", goal="Write replies to customers",
                   backstory="You write short emails.", llm=ShopLLM(model="shop"),
                   verbose=False)
    look = Task(description="Find the order in this message: {question}",
                expected_output="The order's status.", agent=clerk)
    reply = Task(description="Write the customer a reply.",
                 expected_output="A short, friendly email.", agent=writer,
                 guardrail=no_card_numbers)
    return Crew(agents=[clerk, writer], tasks=[look, reply])

The clerk has the lookup tool and finds the order's status. The writer turns that into an email, and the guardrail from the last lesson checks it.

Reading the ticket and routing it

python
class SupportDesk(Flow[Ticket]):
    @start()
    def read_ticket(self):
        found = re.findall(r"\b[A-Z]\d+\b", self.state.message)
        self.state.order_id = found[0] if found else ""

    @router(read_ticket)
    def route(self):
        if not self.state.order_id:
            return "no_order"
        return "refund" if "refund" in self.state.message.lower() else "status"

The flow starts by reading the ticket for an order id. The router sends it one of three ways: a refund, a status question, or a ticket with no order in it, which never reaches a model.

The status and no-order paths

python
    @listen("status")
    def check_status(self):
        result = answer_crew().kickoff(inputs={"question": self.state.message})
        self.state.reply = result.raw

    @listen("no_order")
    def ask_for_order(self):
        self.state.reply = "Dear customer, which order is this about?"

The status path runs the crew and keeps its reply. The no-order path answers without a model at all.

The refund path that pauses

python
    @human_feedback(message="Approve this refund?", provider=ManagerInbox())
    @listen("refund")
    def propose_refund(self):
        return f"Refund {self.state.order_id} in full"

    @router(propose_refund)
    def decide(self, result):
        return "approved" if result.feedback.lower().startswith("yes") else "refused"

A refund is proposed, then paused: @human_feedback hands it to ManagerInbox and stops the flow until someone answers. The second router reads that answer.

Paying or refusing

python
    @listen("approved")
    def pay(self):
        self.state.reply = f"Dear customer, your refund for {self.state.order_id} is on its way."

    @listen("refused")
    def refuse(self):
        self.state.reply = f"Dear customer, a manager could not approve a refund for {self.state.order_id}."

Whatever the manager said, the customer gets a reply, and nothing is paid without a yes.

A status ticket through the whole flow

Example
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
from desk import SupportDesk

desk = SupportDesk(suppress_flow_events=True)
desk.kickoff(inputs={"message": "Where is my order A17?"})
print(desk.state.reply)

Reading the run

  • The ticket named A17, so the router chose the status path and ran the crew.
  • The clerk called the lookup tool and the writer turned its result into the email in state.reply.
  • The refund and no-order paths did not run, because a router sends a ticket down exactly one branch.

A flow next to a plain crew

A crew aloneA flow around a crew
Decides the pathIt does not; tasks run in orderA router, in plain Python
Can pauseNoYes, for a person
Runs the crewAlwaysOnly on the branch that needs it

When a flow belongs around a crew

  • Different tickets need different handling, not one fixed sequence.
  • Some paths must pause for a person before they finish.
  • Some tickets should never reach a model, to save a call.
Watch out
A flow method's own name counts as an event trigger, so @listen("refund") on a method named refund would trigger itself and CrewAI refuses the flow. Keep the label and the method name different, as propose_refund does.
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 finished desk.py

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 = ""


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)


def answer_crew():
    clerk = Agent(role="Order clerk", goal="Find the status of customers' orders",
                  backstory="You can look up any order.", llm=ShopLLM(model="shop"),
                  tools=[lookup_order], verbose=False)
    writer = Agent(role="Reply writer", goal="Write replies to customers",
                   backstory="You write short emails.", llm=ShopLLM(model="shop"),
                   verbose=False)
    look = Task(description="Find the order in this message: {question}",
                expected_output="The order's status.", agent=clerk)
    reply = Task(description="Write the customer a reply.",
                 expected_output="A short, friendly email.", agent=writer,
                 guardrail=no_card_numbers)
    return Crew(agents=[clerk, writer], tasks=[look, reply])


class SupportDesk(Flow[Ticket]):
    @start()
    def read_ticket(self):
        found = re.findall(r"\b[A-Z]\d+\b", self.state.message)
        self.state.order_id = found[0] if found else ""

    @router(read_ticket)
    def route(self):
        if not self.state.order_id:
            return "no_order"
        return "refund" if "refund" in self.state.message.lower() else "status"

    @listen("status")
    def check_status(self):
        result = answer_crew().kickoff(inputs={"question": self.state.message})
        self.state.reply = result.raw

    @listen("no_order")
    def ask_for_order(self):
        self.state.reply = "Dear customer, which order is this about?"

    @human_feedback(message="Approve this refund?", provider=ManagerInbox())
    @listen("refund")
    def propose_refund(self):
        return f"Refund {self.state.order_id} in full"

    @router(propose_refund)
    def decide(self, result):
        return "approved" if result.feedback.lower().startswith("yes") else "refused"

    @listen("approved")
    def pay(self):
        self.state.reply = f"Dear customer, your refund for {self.state.order_id} is on its way."

    @listen("refused")
    def refuse(self):
        self.state.reply = f"Dear customer, a manager could not approve a refund for {self.state.order_id}."
The finished support desk
SupportDesk flowanswer_crew()statusrefundwaitsresumeread_ticketfinds the order idroutestatus, refund, no idpropose_refund@human_feedbackdecideapproved or refusedask_for_orderno order idOrder clerkcalls lookup_orderReply writerguardrail checks itThe shop's ordersA17, C40, not B22ManagerInboxpauses and savesdesk.dbthe paused flowstate.replywhat the customer reads
Hover or tap a piece to see what it is and which lesson built it.
Trace a ticket

Pick one to watch it run, step by step.

The whole desk, with every path a ticket can take. The next lessons run tickets through it.

Try it yourself
  • Add a "cancel" label to the router with its own handler.
  • Give the clerk the MCP tool in place of lookup_order.
  • Move the guardrail to the clerk's task and see which text it checks.

You understood something today that you didn't yesterday.