First flow: steps in plain Python
A flow is a class whose methods are steps: @start marks where it begins, @listen runs a method when another finishes, and kickoff runs it all.
Last updated: 28 Sep, 2026 · CrewAI 1.15
Crews let a model decide. Much of a support desk needs no model: finding an order id is a regular expression. CrewAI's docs recommend starting an application with a flow, which runs ordinary Python and calls a crew only where one is needed.
The Flow decorators
Subclass Flow, mark the first step with @start(), and chain the next with @listen(...), which receives the earlier step's return value.
class Desk(Flow):
@start()
def read_ticket(self): ... # the entry point
@listen(read_ticket)
def answer(self, value): ... # runs when read_ticket finishesView 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?"
The imports
Two imports: the flow decorators, and shop_llm for its settings, though no model runs yet.
import re
import shop_llm
from crewai.flow.flow import Flow, listen, start
The two steps
read_ticket pulls an order id from the message in self.state; answer receives it and replies.
class Desk(Flow):
@start()
def read_ticket(self):
found = re.findall(r"\b[A-Z]\d+\b", self.state["message"])
return found[0] if found else None
@listen(read_ticket)
def answer(self, order_id):
return f"Looking into {order_id}." if order_id else "Which order is this about?"
A two-step flow end to end
Kick it off with a message. inputs goes into the flow's state, and kickoff returns the last method's value.
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, start
class Desk(Flow):
@start()
def read_ticket(self):
found = re.findall(r"\b[A-Z]\d+\b", self.state["message"])
return found[0] if found else None
@listen(read_ticket)
def answer(self, order_id):
return f"Looking into {order_id}." if order_id else "Which order is this about?"
desk = Desk(suppress_flow_events=True)
result = desk.kickoff(inputs={"message": "Where is my order A17?"})
print(result)Looking into A17.
What the two steps returned
- read_ticket found the order id in the message and returned it.
- answer received that id as its argument and built the reply.
- kickoff returned
answer's value, the last method to run.
A message with no order
With no order id, read_ticket returns None and answer asks for one. The state holds the message and an id CrewAI made for this run.
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, start
class Desk(Flow):
@start()
def read_ticket(self):
found = re.findall(r"\b[A-Z]\d+\b", self.state["message"])
return found[0] if found else None
@listen(read_ticket)
def answer(self, order_id):
return f"Looking into {order_id}." if order_id else "Which order is this about?"
desk = Desk(suppress_flow_events=True)
print(desk.kickoff(inputs={"message": "Hello there"}))
print(desk.state)Which order is this about?
{'id': 'c981fa8c-4d89-4388-9bab-d745c7b5d779', 'message': 'Hello there'}A later lesson uses that id to come back to a paused flow. suppress_flow_events=True turns off the panel a flow prints for each method.
A flow vs a crew
| A crew | A flow | |
|---|---|---|
| Who decides the path | A model | Your Python |
| What a step is | A task for an agent | A method |
| Needs a key | For a hosted model | No, until it calls a crew |
When to reach for a flow
- The control logic is plain code: parsing, routing, conditions.
- You want a model only for the parts that truly need one, wrapped in a crew.
@start and no @listen never runs; nothing warns you. The state is a dictionary here, so a mistyped key fails only when that line runs, which the next lesson fixes with a typed state.Related
- Previous: Hierarchical process: a manager for the crew
- Next: Flow state with a Pydantic model
- Reference: Flows
- Add a third method that listens to
answerand adds a signature. - Put two order ids in the message and see which one is used.
- Remove
suppress_flow_events=Trueand count the panels.
Every expert started right here.