Structured output with output_pydantic
output_pydantic makes a task return a Pydantic object instead of free text, so the next piece of code reads fields rather than parsing a sentence.
Last updated: 28 Sep, 2026 · CrewAI 1.15
The writer from the last lesson returns an email as text. The shop's ticket system needs the order id and status as separate fields, checked and typed.
The output_pydantic option
Give a task a Pydantic model as output_pydantic. CrewAI appends the schema to the prompt, then parses and validates the answer into that model.
class Reply(BaseModel):
order_id: str
status: str
reply.output_pydantic = Reply # ask for this shape and validate against it
result.pydantic # a Reply instance
result["status"] # index the result by field nameThe model
A Pydantic model lists the fields and their types. It is plain Pydantic, not a CrewAI class.
from pydantic import BaseModel
class Reply(BaseModel):
order_id: str
status: str
email: str
Scripting the writer's JSON
Writing valid JSON for any schema is beyond a few lines of Python, so the stand-in writer is scripted to answer in JSON for this run. A hosted model given the schema writes it itself.
writer.llm.script = ['{"order_id": "A17", "status": "shipped",'
' "email": "Dear customer, A17 shipped on 3 March."}']View 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?"
A typed reply end to end
The crew from the last lesson, with the reply task now typed.
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order
clerk = Agent(role="Order clerk", goal="Find the status of customers' orders",
backstory="You can look up any order in the shop's system.",
llm=ShopLLM(model="shop"), tools=[lookup_order])
writer = Agent(role="Reply writer", goal="Write replies to customers",
backstory="You write short, friendly emails.",
llm=ShopLLM(model="shop"))
look = Task(description="Find the order in this message: {question}",
expected_output="The order's status.", agent=clerk)
reply = Task(description="Write the customer a reply.",
expected_output="A short, friendly email.", agent=writer)
crew = Crew(agents=[clerk, writer], tasks=[look, reply], verbose=False)
from pydantic import BaseModel
class Reply(BaseModel):
order_id: str
status: str
email: str
reply.output_pydantic = Reply
writer.llm.script = ['{"order_id": "A17", "status": "shipped", "email": "Dear customer, A17 shipped on 3 March."}']
result = crew.kickoff(inputs={"question": "Where is my order A17?"})
print(repr(result.pydantic))
print(result["status"])Reply(order_id='A17', status='shipped', email='Dear customer, A17 shipped on 3 March.') shipped
What the typed result holds
- result.pydantic is a real
Replyobject, its three fields filled from the JSON. - Indexing the result with a field name reads from that object, so
result["status"]is the status string. - CrewAI validated the answer against the schema before handing it back, so the fields have the declared types.
A reply that does not fit
When the model answers with a plain sentence, it cannot become a Reply, and the run stops.
import os
os.environ["OTEL_SDK_DISABLED"] = "true"
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
from crewai import Agent, Crew, Task
from shop_llm import ShopLLM
from tools import lookup_order
clerk = Agent(role="Order clerk", goal="Find the status of customers' orders",
backstory="You can look up any order in the shop's system.",
llm=ShopLLM(model="shop"), tools=[lookup_order])
writer = Agent(role="Reply writer", goal="Write replies to customers",
backstory="You write short, friendly emails.",
llm=ShopLLM(model="shop"))
look = Task(description="Find the order in this message: {question}",
expected_output="The order's status.", agent=clerk)
reply = Task(description="Write the customer a reply.",
expected_output="A short, friendly email.", agent=writer)
crew = Crew(agents=[clerk, writer], tasks=[look, reply], verbose=False)
from pydantic import BaseModel
class Reply(BaseModel):
order_id: str
status: str
email: str
reply.output_pydantic = Reply
writer.llm.script = ["A17 shipped on 3 March."]
try:
crew.kickoff(inputs={"question": "Where is my order A17?"})
except Exception as error:
print(type(error).__name__)
print(error)ConverterError Failed to convert text into a Pydantic model due to error: Agent must be provided if converter_cls is not specified.
The message in 1.15.22 mentions a missing agent although the task has one; the real cause is the text, which does not fit the schema. The next lesson sends a wrong answer back for another try.
output_pydantic vs raw text
| Plain text (raw) | output_pydantic | |
|---|---|---|
| What you get back | A string to parse yourself | A typed object with named fields |
| A wrong shape | Passes through unnoticed | Raises ConverterError |
| Reading a field | String slicing or regex | result.pydantic.status |
When to type a task's output
- The answer feeds another program: a ticket system, a database row, an API call.
- You need one field, not a paragraph, and want a hard error when it is missing.
output_pydantic makes a mismatch stop the whole run, so pair it with the guardrail retry from the next lesson when a model may answer in prose. Use output_json=Reply instead when you want a dict in result.json_dict rather than an object.Related
- Previous: Sequential tasks: two agents in a row
- Next: Guardrails: checking a task's answer
- Reference: Tasks
- Add a field
refund: bool = FalsetoReplyand run the first example. - Script a reply whose
statusis a number and read the error. - Use
output_json=Replyinstead and printresult.json_dict.
Every expert started right here.