← Back to Agentic AI map
Lesson 6.4 · CrewAI

Defining a Task

A task says what to do and what the result should look like. Learn description, expected_output and agent - then make the result reliable: a guardrail that checks it in code (from 0 of 4 accepted to 4 of 4), output_pydantic for a real Python object, output_file, and the input mistake that gives no error.

crewai

What you will be able to do

  • Write a task with description, expected_output and agent
  • Use placeholders like {topic} and fill them with kickoff(inputs=...)
  • Check a result in code with a guardrail, and retry automatically
  • Design a guardrail that the model can actually satisfy
  • Get structured results with output_pydantic, and save them with output_file
  • Recognise the task mistakes - including one with no error

The idea, in plain English

An agent is WHO works (Lesson 6.3). A task is WHAT they must do: a description of the job, the expected output, and the agent who does it.

In Lesson 6.3 no prompt could make llama3 keep to a word limit. A task can do better: a guardrail is a small Python function that checks the result, and if it is not good enough, sends the agent back with a message saying what to fix.

Building a guardrail that works took us three tries - each one is in this lesson. CrewAI 1.15.27, llama3 on Ollama.

Worked example: A teacher task with a 60-word target, checked by a guardrail on four topics in three versions - plus a facts task that returns a Pydantic object and saves it to a file.

workflowA task with a guardrailstep 1 / 3

1 - Ask the model to fix everything

The guardrail rejected "Here is..." lines and answers over 80 words. The retry prompt says "Try again" - and llama3 answered "Here’s another attempt:" every time.

accepted
0 of 4

Four topics, three versions of the guardrail. Real llama3 runs.

Words you will see in this lesson

The parts of a task.

Small dictionary
descriptionThe job: what the agent must do now. Required.
expected_outputWhat a good result looks like. Required - but only a hint to the model.
PlaceholderA {name} in the text, filled in by kickoff(inputs={"name": ...}).
GuardrailA function that checks the result: accept it, or send it back with a reason.
TaskOutputWhat the guardrail receives: .raw is the text.
output_pydanticA Pydantic model the result must fit; you get a real Python object.

An everyday example: a homework assignment

  • The assignment says what to do: "Explain how a loop works." (description)
  • And what to hand in: "One paragraph, at most 60 words." (expected_output)
  • The teacher checks it. Too long? "Please shorten it." (the guardrail sends it back)
  • A good teacher fixes the spelling of the title themselves instead of returning the whole essay for it. (Fix small things in code.)

The parts of a task

  • description and expected_output are required. Leaving out expected_output raised a Pydantic error: "expected_output - Field required".
  • agent: who does it. In a sequential crew it is required: "Agent is missing in the task with the following description: Say hello."
  • Placeholders: write {topic} in the text and pass kickoff(inputs={"topic": "Python lists"}).
  • CrewAI turns description and expected_output into the user prompt (Lesson 6.1).

Example 1 - a guardrail, version 1

A guardrail is a function that receives the TaskOutput and returns a pair:

  • (True, result) - accept. The result becomes the task’s output.
  • (False, "what to fix") - reject. CrewAI sends the message back to the agent and tries again.
  • guardrail_max_retries=3 (the default): after 3 failed retries, the task raises an exception.
  • Version 1 rejects an introduction line ("Here is...") and anything over 80 words - and asks the model to fix both.
guardrail.py - version 1
import time from crewai import LLM, Agent, Crew, Task, TaskOutput llm = LLM(model="ollama/llama3", base_url="http://localhost:11434", temperature=0) teacher = Agent( role="Friendly teacher for complete beginners", goal="Explain technical ideas so that a 14-year-old understands them", backstory="You never use a technical word without explaining it. You always use one everyday comparison.", llm=llm, ) attempts = [] def short_and_clean(output: TaskOutput): """A guardrail: return (True, result) to accept, or (False, "what to fix") to retry.""" text = output.raw.strip() words = len(text.split()) attempts.append(words) if text.lower().startswith(("here is", "here's")): return False, "Start directly with the explanation. Do not write an introduction line." if words > 80: return False, f"Your answer has {words} words. Rewrite it in at most 80 words." return True, text for topic in ["a database index", "an API", "a variable", "a loop"]: attempts.clear() task = Task( description=f"Explain {topic}.", expected_output="At most 80 words. One paragraph.", agent=teacher, guardrail=short_and_clean, guardrail_max_retries=3, ) start = time.perf_counter() try: result = Crew(agents=[teacher], tasks=[task]).kickoff() print(f"{topic:18} attempts (words): {attempts} -> accepted, {time.perf_counter() - start:.1f} s") except Exception as error: print(f"{topic:18} attempts (words): {attempts} -> FAILED: {type(error).__name__}: {str(error)[:150]}")
Output - 0 of 4 accepted
a database index attempts (words): [86, 85, 85, 85] -> FAILED: Exception: Task failed guardrail validation after 3 retries. Last error: Start directly with the explanation. Do not write an introduction line. an API attempts (words): [130, 130, 130, 130] -> FAILED: Exception: Task failed guardrail validation after 3 retries. Last error: Start directly with the explanation. Do not write an introduction line. a variable attempts (words): [82, 84, 86, 86] -> FAILED: Exception: Task failed guardrail validation after 3 retries. Last error: Start directly with the explanation. Do not write an introduction line. a loop attempts (words): [96, 87, 71, 71] -> FAILED: Exception: Task failed guardrail validation after 3 retries. Last error: Start directly with the explanation. Do not write an introduction line.

Why version 1 failed - the retry prompt

Every topic failed because of the introduction line. We recorded what CrewAI sent on each retry:

  • The retry message shows the error, the previous result, and ends with "Try again, making sure to address the validation error."
  • "Try again" made llama3 start with "Here’s another attempt:" - the very line the guardrail rejects.
  • So the retry itself caused the next failure, again and again.
  • Lesson: do not ask a model to fix what your code can fix in one line.
Retry 2, as sent to llama3 (end of the message)
### Previous attempt failed validation: Start directly with the explanation. Do not write an introduction line. ### Previous result: Here's another attempt: So, you know how you have a special box in your room where you keep your favorite toys or books? In computer programming, a "variable" is like that box, but for storing pieces of information. You can put a value, like a number or a word, into the box, and then use that value later in your program. It's like labeling a box "My Favorite Book" and then being able to find that book again later by looking at the label. Try again, making sure to address the validation error. Provide your complete response:

Versions 2 and 3 - fix in code, ask for less

Version 2 removes the introduction line itself and only asks the model to shorten. Version 3 also asks for 60 words while still accepting up to 80:

  • A guardrail may CHANGE the result: (True, text) returns the cleaned text as the task’s output.
  • Version 2: 2 of 4 accepted. The other two got stuck at 81 and 82 words - llama3 cannot count words exactly.
  • Version 3: 4 of 4 accepted, with 0 or 1 retry each. Asking for less gives the model room to miss a little.
  • The same idea works for any rule: ask for a little more than you need, check in code what you really need.
Version 3 - the changes
def short_and_clean(output: TaskOutput): """A guardrail: return (True, result) to accept, or (False, "what to fix") to retry.""" lines = output.raw.strip().splitlines() if lines and lines[0].lower().startswith(("here is", "here's")): # fix small things in code ... lines = lines[1:] text = "\n".join(lines).strip() words = len(text.split()) attempts.append(words) if words > 80: # ... ask the model only for real work return False, f"Your answer has {words} words. Rewrite it in at most 60 words." return True, text task = Task( description=f"Explain {topic}.", expected_output="At most 60 words. One paragraph.", # ask for less ... agent=teacher, guardrail=short_and_clean, # ... check what you really need (80) guardrail_max_retries=3, )
Output - version 2, then version 3
version 2 (clean in code, ask for 80) a database index attempts (words): [86, 82, 82, 82] -> FAILED: Exception: Task failed guardrail validation after 3 retries. Last error: Your answer has 82 words. Rewrite it in at most 80 words. an API attempts (words): [119, 79] -> accepted, 17.0 s a variable attempts (words): [82, 81, 81, 81] -> FAILED: Exception: Task failed guardrail validation after 3 retries. Last error: Your answer has 81 words. Rewrite it in at most 80 words. a loop attempts (words): [96, 80] -> accepted, 14.2 s version 3 (clean in code, ask for 60, accept 80) a database index attempts (words): [86, 76] -> accepted, 12.6 s an API attempts (words): [107, 62] -> accepted, 14.8 s a variable attempts (words): [77] -> accepted, 5.7 s a loop attempts (words): [88, 48] -> accepted, 13.3 s --- last accepted answer: Imagine making cookies! You mix, scoop, bake, cool, and package. You do this many times to make a big batch. A loop is like that process. It's a way for a computer to repeat a set of steps, like a recipe, over and over again until it's done.

Watch out: Every retry is another model call: "an API" took 14.8 s instead of about 5 s. Count the cost of retries, and keep guardrail_max_retries small.

Example 2 - structured output and a file

When another program will use the result, ask for a structure, not text:

  • output_pydantic=Facts: the result must fit the Facts model. result.pydantic is then a real Facts object.
  • Field descriptions ("Exactly 3 short facts") guide the model.
  • output_file="output/facts.json": the result is also saved to a file; CrewAI made the output folder.
  • It worked with llama3: topic, three facts and the difficulty came back as fields.
structured.py
from pydantic import BaseModel, Field from crewai import LLM, Agent, Crew, Task llm = LLM(model="ollama/llama3", base_url="http://localhost:11434", temperature=0) class Facts(BaseModel): topic: str facts: list[str] = Field(description="Exactly 3 short facts") difficulty: str = Field(description="beginner, intermediate or advanced") researcher = Agent( role="Researcher", goal="Collect key facts about a topic", backstory="You write short, correct facts. You never write articles.", llm=llm, ) task = Task( description="Give 3 key facts about {topic}.", expected_output="The topic, 3 short facts, and how difficult the topic is for a beginner.", agent=researcher, output_pydantic=Facts, # the answer must fit this model output_file="output/facts.json", # and is also saved to a file ) result = Crew(agents=[researcher], tasks=[task]).kickoff(inputs={"topic": "Python lists"}) facts = result.pydantic # a real Facts object, not text print(type(facts).__name__, "|", facts.topic, "|", facts.difficulty) for f in facts.facts: print(" -", f) print("file:", open("output/facts.json").read()[:160])
Output
Facts | Python Lists | beginner - Python lists are a built-in data type that can contain multiple values of different data types, such as strings, integers, floats, and other lists. - Lists are denoted by square brackets [] and can be created using the list() function or by enclosing values in square brackets. - Lists are mutable, meaning they can be modified after creation, and they support various operations such as indexing, slicing, and concatenation. file: {"topic":"Python Lists","facts":["Python lists are a built-in data type that can contain multiple values of different data types, such as strings, integers, flo

Task mistakes - one gives no error

  • No expected_output: an error at once (Field required).
  • No agent in a sequential crew: an error at once (missing_agent_in_task).
  • A placeholder with the WRONG input name (inputs={"subject": ...} for {topic}): ValueError "Template variable 'topic' not found in inputs dictionary".
  • But NO inputs at all: no error. The model received the literal text "Explain {topic}." and answered "I'm ready to help!"
  • So: always pass inputs when your tasks have placeholders - and check the first results.
Output - four mistakes
no expected_output expected_output Field required [type=missing, input_value={'description': 'Say hell...nswer in one sentence.)}, input_type=dict] no agent ValidationError: 1 validation error for Crew Sequential process error: Agent is missing in the task with the following description: Say hello. [type=missing_agent_in_task, ...] inputs={"subject": "loops"} for "Explain {topic}." ValueError: Missing required template variable 'Template variable 'topic' not found in inputs dictionary' in description no inputs at all NO ERROR. The model received: 'Explain {topic}.' -> I'm ready to help!

The recipe

  • 1. description: the job, with {placeholders} for what changes.
  • 2. expected_output: what a good result looks like - ask for a bit less than your hard limit.
  • 3. agent: who does it.
  • 4. guardrail: check hard rules in code; fix small things yourself; return (False, "clear reason") only for real work.
  • 5. output_pydantic when a program uses the result; output_file to keep it.
  • 6. Always pass inputs=... and check the first results.

Tasks at a glance

Create

Required: description, expected_output.

Task(description="...", expected_output="...", agent=a)
Placeholder

Filled at kickoff.

Task(description="Explain {topic}.")
crew.kickoff(inputs={"topic": "loops"})
Guardrail

Check and retry.

def check(output: TaskOutput):
    return (True, text) or (False, "fix this")
Retries

Default 3.

Task(..., guardrail=check, guardrail_max_retries=2)
Structured

A real object.

Task(..., output_pydantic=Facts)
result.pydantic
Save

Also to a file.

Task(..., output_file="output/facts.json")

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Another rule

“Add a check to the guardrail: the answer must contain the word "like" (an everyday comparison).”

Fewer retries

“Set guardrail_max_retries=1. How many topics are accepted now?”

Validate facts

“Add a guardrail to the Facts task that rejects anything other than exactly 3 facts.”

The silent mistake

“Run a task with {topic} and no inputs. Then add a check that raises an error if "{" is still in the description.”

What usually goes wrong

Asking the model to fix trivial things

The retry prompt made llama3 write "Here’s another attempt:" every time. Strip such lines in code.

A limit with no margin

Asking for exactly the limit left answers stuck at 81-82 words. Ask for less, accept up to the limit.

Trusting expected_output

It is a hint in the prompt, not a check.

Forgetting inputs

With no inputs at all, "{topic}" was sent to the model as text - no error.

Key points

  • A task needs a description and an expected_output, and usually an agent.
  • A guardrail returns (True, result) to accept or (False, "what to fix") to retry - default 3 retries, then an exception.
  • Fix small things in code; the retry prompt itself caused llama3’s "Here’s another attempt:".
  • Ask for less than the limit, check the real limit: 0 -> 2 -> 4 of 4 accepted.
  • output_pydantic gives a real object (result.pydantic); output_file saves it.
  • Missing inputs gave no error - the model got "{topic}" literally.

Quick check before you move on

What must a guardrail return?
A pair: (True, result) to accept, or (False, "what to fix") to retry.
What happens after guardrail_max_retries failed retries?
The task raises an exception: "Task failed guardrail validation after 3 retries."
Why did version 1 fail on every topic?
The retry prompt said "Try again", so llama3 started with "Here’s another attempt:", which the guardrail rejected.
How do you get a Python object instead of text?
Task(output_pydantic=Model), then result.pydantic.

Quiz

  1. 1.

    Version 2 left two answers at 81-82 words. What fixed it?

  2. 2.

    Can a guardrail change the result?

  3. 3.

    Which input mistake gave no error?

  4. 4.

    What is the cost of a guardrail?

Interview questions

How do you make LLM task outputs reliable in CrewAI?

Specify the expected output clearly, request structured output (output_pydantic) where possible, and validate with guardrails that deterministically fix trivial issues and return precise feedback for real problems, with a small retry budget.

What makes a good guardrail feedback message?

Specific and actionable ("Your answer has 107 words; rewrite it in at most 60"), about one thing at a time, and never about something code could fix itself.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...