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
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
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
@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
@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
@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
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)Dear customer, A17 shipped on 3 March.
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 alone | A flow around a crew | |
|---|---|---|
| Decides the path | It does not; tasks run in order | A router, in plain Python |
| Can pause | No | Yes, for a person |
| Runs the crew | Always | Only 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.
@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.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 finished desk.py
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}."
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.
Related
- Previous: Desk state and a reply guardrail
- Next: Three tickets through the desk
- Reference: CrewAI docs, Flows
- 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.