Desk refund: pausing for a manager
A refund is the one thing the desk will not do on its own: the flow stops, saves itself, and waits for a manager, and a refusal is a real outcome with its own reply.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The three-ticket run showed the happy path and a not-found ticket. A refund is different: it pauses. This lesson runs one, then resumes it two ways, and one of them is the desk refusing to pay.
View the code here
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}."
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?"
A refund that stops and saves
from crewai.flow.persistence import SQLiteFlowPersistence
from desk import SupportDesk
db = SQLiteFlowPersistence("desk.db")
desk = SupportDesk(persistence=db, suppress_flow_events=True)
pending = desk.kickoff(inputs={"message": "Please refund order A17, it arrived broken."})
flow_id = pending.context.flow_idThe refund path reaches propose_refund, which hands the proposal to the manager's inbox and pauses. kickoff returns a HumanFeedbackPending object carrying the flow id, so the program can stop and pick the ticket up later.
The flow pausing for the manager
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
from crewai.flow.persistence import SQLiteFlowPersistence
from desk import SupportDesk
db = SQLiteFlowPersistence("desk.db")
desk = SupportDesk(persistence=db, suppress_flow_events=True)
pending = desk.kickoff(inputs={"message": "Please refund order A17, it arrived broken."})
flow_id = pending.context.flow_idto the manager: Refund A17 in full ╭─────────────────────────────── ⏳ Flow Paused ───────────────────────────────╮ │ │ │ Flow Paused - Waiting for Feedback │ │ Name: SupportDesk │ │ ID: df4a6c8d-a4db-4fc0-af77-dabe6d706b8a │ │ │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯
The proposal reached the manager and the flow stopped there. Its state is in desk.db, so the process can end and nothing is lost. No refund has been made.
Resuming to a refusal, the failure path
later = SupportDesk.from_pending(flow_id, db, suppress_flow_events=True)
later.resume("No, it was delivered intact")
print(later.state.reply)Dear customer, a manager could not approve a refund for A17.
from_pending rebuilds the flow from the saved state, and resume carries the manager's answer into the second router. An answer that does not start with "yes" takes the refused branch, so the desk tells the customer no refund was made. The flow never reaches pay, which is the whole point of the pause.
Resuming to a payment
later = SupportDesk.from_pending(flow_id, db, suppress_flow_events=True)
later.resume("Yes, refund it")
print(later.state.reply)Dear customer, your refund for A17 is on its way.
The same saved ticket, resumed with a yes, takes the approved branch and pay writes the reply. Nothing is paid until that answer arrives.
Pick one to watch it run, step by step.
The two ends of a refund
| Refused | Approved | |
|---|---|---|
| Manager answers | Anything not starting with "yes" | Starts with "yes" |
| Branch taken | refused | approved |
| Reaches pay | No | Yes |
| Reply to customer | No refund was made | Refund is on its way |
When a pause for a person belongs in a flow
- Any action that spends money or cannot be undone, such as a refund.
- A step that needs a person's judgement the model should not make alone.
- Work that may sit for hours before someone answers, so the state must be saved.
kickoff for each ticket, and read the outcome from state.reply after resume returns.What this course left out
CrewAI is large. These are the parts a first course does not cover, and where to read each one.
| Topic | What it is for | Docs |
|---|---|---|
| Task human_input | A person approves or edits a task's result after it runs | human-in-the-loop |
| Reasoning | An agent that thinks a problem through before it acts | reasoning |
| Planning | A model that writes a plan into every task before the crew runs | planning |
| Async | Running crews at the same time with kickoff_async and kickoff_for_each | kickoff-async |
| Checkpointing | Saving a flow between steps so a long run can restart | checkpointing |
| Training | Feeding human corrections back so a crew improves | training |
| Multimodal | Agents that read images, not only text | multimodal agents |
| CLI | crewai create and the project layout it builds | CLI |
| Streaming | Reading a run's output as it is produced | streaming |
| Testing | Scoring a crew's runs with crewai test | testing |
Related
- Previous: Three tickets through the desk
- See also: Integrations: from stand-in to production
- Reference: CrewAI docs, human feedback in flows
- Resume with "maybe" and read which branch that takes.
- Add a rule that refunds under 20 need no approval.
- Store each finished reply in the crew's memory, under the customer's scope.
This is what real progress feels like.