Router: choosing a path in a flow
A @router method returns a label, and every method that listens for that label runs next, which is how a flow takes one path for refunds and another for order status.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The typed flow so far treated every ticket the same. A refund and a status question need different handling, chosen in plain Python.
The @router decorator
@router works like @listen, but the method's return value is a label. Each @listen("label") waits for the matching label.
@router(read_ticket)
def route(self):
return "refund" if ... else "status" # the label to follow
@listen("refund") # runs only on the "refund" label
def hold_refund(self): ...A handler named after its label
A method's own name counts as a trigger, so a method called refund that listens for "refund" would run itself forever. CrewAI refuses the flow when it is built.
@listen("refund")
def refund(self): # same name as the label it listens for
...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)
names = [t["function"]["name"] for t in tools or []]
return self.decide(messages, names)
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)
wanted = "refund_order" if "refund" in text.lower() else "lookup_order"
matches = [name for name in tools if name.endswith(wanted)]
if orders and matches:
args = json.dumps({"order_id": orders[0]})
return [{"id": f"call_{orders[0]}", "type": "function",
"function": {"name": matches[0], "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 flow that listens to itself
The mistake shows up the moment the flow is created, not when the file is read.
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
import re
import shop_llm
from crewai.flow.flow import Flow, listen, router, start
from pydantic import BaseModel
class Ticket(BaseModel):
message: str = ""
order_id: str = ""
reply: str = ""
class Desk(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):
return "refund" if "refund" in self.state.message.lower() else "status"
@listen("refund")
def refund(self):
self.state.reply = f"A manager will review the refund for {self.state.order_id}."
desk = Desk()
Traceback (most recent call last):
File "main.py", line 32, in <module>
desk = Desk()
pydantic_core._pydantic_core.ValidationError: 1 validation error for Desk
Value error, Invalid flow definition for Desk: methods.refund.listen listen condition 'refund' references the handler name 'refund'. A listener triggered by its own completion creates an infinite loop. Listen to a different method or event, or rename the handler. [type=value_error, input_value={}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.12/v/value_errorRename the handler so its name differs from every label it listens for.
Distinct names
Rename the handler so its name differs from every label. Now they are hold_refund and check_status, one for each label the router returns.
@listen("refund")
def hold_refund(self):
self.state.reply = f"A manager will review the refund for {self.state.order_id}."
@listen("status")
def check_status(self):
self.state.reply = f"Looking into {self.state.order_id}."One label per ticket
Run a status question and a refund through the flow; only the matching handler runs.
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
import re
import shop_llm
from crewai.flow.flow import Flow, listen, router, start
from pydantic import BaseModel
class Ticket(BaseModel):
message: str = ""
order_id: str = ""
reply: str = ""
class Desk(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):
return "refund" if "refund" in self.state.message.lower() else "status"
@listen("refund")
def hold_refund(self):
self.state.reply = f"A manager will review the refund for {self.state.order_id}."
@listen("status")
def check_status(self):
self.state.reply = f"Looking into {self.state.order_id}."
for message in ["Where is my order A17?", "Please refund order A17."]:
desk = Desk(suppress_flow_events=True)
desk.kickoff(inputs={"message": message})
print(desk.state.reply)Looking into A17. A manager will review the refund for A17.
What the router chose
- The status question routed to
check_status, which wrote the status reply. - The refund routed to
hold_refund, which wrote the manager-review reply. - A label no method listens for ends the flow there, which a typo makes easy to do.
@router vs @listen
| @listen(method) | @router(method) | |
|---|---|---|
| Triggers on | A method finishing | A method finishing |
| What runs next | This one method | Whichever listens for the returned label |
| Return value | Passed to the listener | Used as the label to follow |
When to route a flow
- One incoming thing needs different handling by type: refund, status, complaint.
- The branch is a plain Python decision, not something a model should make.
Related
- Previous: Flow state with a Pydantic model
- Next: Crew in a flow: one step of a flow
- Reference: Flows
- Add a label
"no_order"for messages without an order id, and a handler for it. - Return
"Refund"with a capital R and see what runs. - Import
or_and add a method that listens toor_("refund", "status").
You understood something today that you didn't yesterday.