How to orchestrate specialized AI agents with CrewAI – and what changes once the crew runs in production. With lessons from two of my own systems.
A single AI agent quickly hits its limits on complex, multi-step tasks. The obvious answer: multiple specialized agents that work together like a team.
CrewAI is a Python framework built for exactly that, at version 1.15 as of October 2026. I use it in two of my own systems: at Erst Recht, an AI platform for initial legal assessments under German law, and in a research project where an autonomous multi-agent system evaluates studies on dietary supplements. This article covers the basics first, then what is left of them in production. In short: less agent magic, more code.
The Three Core Concepts
Instead of one agent that can do everything, you assemble a team: roles, tasks, process. The examples show a generic crew that triages incoming support requests.
1. Agents: The Specialists
Each agent has a role, a goal, and a backstory. You plug in the model via the LLM class, with a provider prefix in the model name.
from crewai import Agent, LLM
llm = LLM(
model="provider/model-name",
max_tokens=4096,
timeout=120, # hard ceiling per call
)
analyst = Agent(
role="Support Analyst",
goal="Classify requests and assess their urgency",
backstory="""You analyze support requests for a B2B software product.
Reply with the assessment only, no introduction.""",
llm=llm,
allow_delegation=False,
)
Best Practice: Specific Roles
Instead of "Analyst," use "support analyst for B2B software" with a clearly scoped goal. Just as important is what the agent should not do.
2. Tasks: The Assignments
Tasks define what needs to be done. Via context, a task receives the result of an earlier task; via output_pydantic, you enforce a fixed output format instead of free text.
from crewai import Task
task_assess = Task(
description="Assess this request: {request}",
expected_output="Category, priority, and a short rationale",
agent=analyst,
output_pydantic=TicketAssessment, # schema instead of free text
context=[task_classify], # result of the previous agent
)
3. Crew: The Team
The crew brings agents and tasks together and defines how they collaborate.
from crewai import Crew, Process
crew = Crew(
agents=[classifier, analyst, writer],
tasks=[task_classify, task_assess, task_reply],
process=Process.sequential,
)
result = crew.kickoff(inputs={"request": text})
assessment = result.tasks_output[1].pydantic # typed, no regex
The alternative is Process.hierarchical: a manager LLM assigns tasks dynamically and reviews the results. According to the docs, that requires a manager_llm or a manager_agent. In production, I rely on fixed ordering. A process I know in advance is one I can test and debug.
Crews vs. Flows: The Part Tutorials Skip
A crew is a group of agents. A flow is an event-driven process around it: methods with @start() and @listen(), branching with @router(), plus typed state as a Pydantic model. A flow step can run a crew – or plain Python code. CrewAI itself recommends starting with a flow for production. Here is the principle, heavily simplified, in my research project:
from crewai.flow.flow import Flow, listen, start
from pydantic import BaseModel
class RunState(BaseModel):
topic_id: str = ""
paper_ids: list[str] = []
evaluation_ids: list[str] = []
draft_id: str | None = None
class ResearchFlow(Flow[RunState]):
@start()
async def discover(self):
# plain code: load and deduplicate studies
self.state.paper_ids = await discover_papers(self.state.topic_id)
@listen(discover)
async def evaluate(self):
# fixed scoring formula
self.state.evaluation_ids = await evaluate_papers(self.state.paper_ids)
@listen(evaluate)
async def synthesize(self):
# LLM step with a fixed output schema
self.state.draft_id = await write_draft(self.state.evaluation_ids)
# then: edit → fact-check → validate → publish
What's notable is what's missing: this system contains not a single crew. I had planned one crew per stage. What I built is a set of stages as plain async functions that call the model directly, each with a fixed output schema. From CrewAI, I only use flows there: ordering, state, scheduled start. Every stage has a clear input and a clear output. That leaves little for a self-planning agent to decide.
Two Systems at a Glance
Erst Recht
Several specialized agents, orchestrated with CrewAI, plus a legal knowledge base. The result is a structured initial legal assessment for non-lawyers.
Dietary supplement research project
One flow with fixed stages: find studies, score them, draft the article, edit, fact-check, validate the evidence, publish. The last stage is plain code with no LLM.
Five Rules for Crews in Production
1. A Schema That's Too Strict Can Kill the Whole Run
Hard value constraints in the output schema, such as a Literal, are risky. If the model returns a synonym, Pydantic raises a ValidationError, and depending on the version CrewAI re-raises it out of crew.kickoff(). The whole run aborts, paid tokens are lost, and fallback code after kickoff() never runs.
The fix: the schema describes structure only. The code checks the allowed values after parsing. Here it is for a priority field:
# before: too strict, one synonym kills the whole run
class TicketAssessment(BaseModel):
priority: Literal["high", "medium", "low"]
# after: schema for structure ...
class TicketAssessment(BaseModel):
priority: str = Field(description="Allowed: 'high', 'medium', 'low'")
# ... and code for values, after parsing
ALLOWED = {"high", "medium", "low"}
assessment = result.tasks_output[1].pydantic
if assessment.priority.strip().lower() not in ALLOWED:
flag_for_review(assessment) # flag the value, the run is preserved
Newer CrewAI versions also offer task guardrails: a function validates the output, and on failure the error goes back to the agent, by default up to three times (guardrail_max_retries). For format errors, that's cleaner. But every retry is another paid model call.
2. Agents Invent Sources
In the research project, every claim in an article has to be backed by paper IDs. The prompt forbids invented IDs. The code still doesn't rely on that:
known_ids = set(paper_abstracts.keys())
for p in parsed.paragraphs:
support = [pid for pid in p.supporting_paper_ids if pid in known_ids]
if p.is_claim and not support:
continue # a claim without a real source is dropped
paragraphs.append(...)
Every cited ID is checked against the papers that were actually in the prompt. More hard checks follow: if a claim can't be traced to a real source, the stage fails without retry. The fact-check verifies that cited DOIs exist and that the paper actually supports the claim.
3. No Model Grades Its Own Work
In the research project, the model that writes and the model that checks come from different model families. A model proofreading its own text easily misses the same mistakes it made. The check is mandatory: if it didn't run, nothing gets published, instead of silently downgrading to "unverified."
4. User Input Is Data, Not Instructions
Everything that comes from outside is data, not a command: user input, uploaded documents, abstracts from databases. Such text belongs clearly separated from the instructions in the prompt. In the research project, a deterministic scanner also checks every abstract for typical injection patterns before it reaches a model. Suspicious text doesn't get through unfiltered. Why that's not a luxury is covered in my article on the lethal trifecta.
5. Cost and Runtime Need Hard Limits
In the research project, every model call first passes through a cost governor with fixed limits per run, per day, and per month. If a run breaks its limit, it is aborted. If the month breaks its limit, publishing switches itself off automatically.
Just as important is a timeout on every model call. A hung call must not block a worker. Long crew runs also don't belong in a synchronous API request; the CrewAI docs recommend kickoff_async for that. If you build agents with tools, set the per-agent limits deliberately: max_iter (default: 20), max_execution_time, max_rpm.
Code or Agent? Where I Draw the Line
The most important lesson from both systems: a large part of the work doesn't belong in an agent at all.
- Data collection: In the research project, plain adapters fetch studies from databases such as PubMed, with rate limits and deduplication. No agent searches on its own there.
- Providing knowledge: If agents work with a knowledge base, decide deliberately whether the agent searches on its own or whether code fetches the relevant passages up front. Pre-fetched context is reproducible and can be logged. A search tool gives the agent more freedom – and more ways to get lost. How to build such a knowledge base is covered in my Docling article.
- Scoring: A study's quality score is a fixed formula based on classic criteria such as study design and sample size.
- Evidence level: The validator always takes the lower of the model's proposal and the computed value. Upgrading is impossible.
- Publishing: Plain code with no LLM, with an approval step secured in more than one place.
Rule of Thumb
Anything with one clearly correct answer belongs in code. Agents get the steps that require judgment. And even there, code checks the result.
And then there's the human. In the original design, the research project was "fully autonomous." What I built is an approval gate: the first articles only go live with human sign-off. How the system turns its own failures into issues and pull requests is described in Loops, Not Prompts.
When You Don't Need a Crew
- The steps are fixed. If every step has a clear input and a fixed output schema, a flow with plain functions is enough. That's how my research project runs.
- The answer has to be fast. Every sequential agent is another LLM call. For an analysis nobody is waiting on live, that's fine. At chat speed, it gets sluggish.
- More agents for their own sake. Every additional agent costs tokens and latency. Whether a more elaborate topology delivers better results is for a test to show, not the architecture diagram.
- You can't check the result. Without a schema, a verification step, and a cost ceiling, a crew doesn't belong in front of users.
Checklist for Your First Crew in Production
- Start with a flow. Long-lived state belongs in the database, not in flow state.
- Every task gets
output_pydantic: schema for structure, code for values. - Check every source, ID, and number against the input in code.
- Verify with a different model family than the one that wrote.
- Add timeouts and cost ceilings before the first user arrives.
- Clearly separate user input and external text and treat it as data.
- At the start, put a human before publishing.
Conclusion
CrewAI makes getting started easy: roles, tasks, crew, done. In production, the center of gravity shifts. For me, most of the work sits in the code around the agents: schemas that don't abort a run, checks that weed out invented sources, limits that cap costs. The agents take on what requires judgment. Code handles everything else.
Tools used:
Planning a multi-agent system, or want to make an existing crew production-ready? Get in touch, I'm happy to help with architecture and implementation.