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 →

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.

python
@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.

python
    @listen("refund")
    def refund(self):        # same name as the label it listens for
        ...
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)
        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.

Example
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()

Rename 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.

python
    @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.

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

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 onA method finishingA method finishing
What runs nextThis one methodWhichever listens for the returned label
Return valuePassed to the listenerUsed 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.
Watch out. A handler must not share its name with a label it listens for, or the flow is refused at creation. A returned label that no method listens for silently ends the run.
Try it yourself
  • 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 to or_("refund", "status").

You understood something today that you didn't yesterday.