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 →

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.

python
class Desk(Flow):
    @start()
    def read_ticket(self): ...      # the entry point

    @listen(read_ticket)
    def answer(self, value): ...     # runs when read_ticket finishes
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?"

The imports

Two imports: the flow decorators, and shop_llm for its settings, though no model runs yet.

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

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

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

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.

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

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 crewA flow
Who decides the pathA modelYour Python
What a step isA task for an agentA method
Needs a keyFor a hosted modelNo, 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.
Watch out. A method with no @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.
Try it yourself
  • Add a third method that listens to answer and adds a signature.
  • Put two order ids in the message and see which one is used.
  • Remove suppress_flow_events=True and count the panels.

Every expert started right here.