n4nAI

CrewAI task design tutorial: context sharing between tasks

Learn how to share context between CrewAI tasks with practical code examples, covering output passing, memory, and callback patterns for multi-agent workflows.

n4n Team4 min read922 words

Audio narration

Coming soon — every post will get a voice note here.

If you’ve built anything beyond a toy CrewAI pipeline, you’ve hit the context sharing problem: Task B needs Task A’s output, but the framework doesn’t make this automatic. This crewai task context sharing tutorial walks through the three patterns that actually work in production — direct output passing, shared memory, and callback-driven state — with runnable code at each step.

Prerequisites

You need Python 3.10+ and a working OpenAI-compatible endpoint. The examples use crewai>=0.80.0 and langchain-openai for the LLM wrapper. Install dependencies:

pip install crewai langchain-openai python-dotenv

Create a .env file with your API credentials:

OPENAI_API_KEY=your-key-here
OPENAI_API_BASE=https://api.openai.com/v1  # or your gateway endpoint

If you route through a gateway like n4n.ai, set OPENAI_API_BASE to its endpoint and use your gateway key — the OpenAI-compatible interface means zero code changes.

The Default Behavior: Isolated Tasks

By default, CrewAI tasks run independently. Each task receives only its own description and expected_output. The agent’s prompt includes the task description but not sibling task outputs unless you explicitly wire them.

# isolated_tasks.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
import os

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1
)

researcher = Agent(
    role="Research Analyst",
    goal="Find key facts about a topic",
    backstory="You extract concise, verifiable information.",
    llm=llm,
    verbose=True
)

writer = Agent(
    role="Technical Writer",
    goal="Write a summary from research",
    backstory="You transform raw facts into readable prose.",
    llm=llm,
    verbose=True
)

research_task = Task(
    description="Research the key differences between REST and GraphQL APIs. List 5 concrete differences.",
    expected_output="A bulleted list of 5 differences with brief explanations",
    agent=researcher
)

write_task = Task(
    description="Write a 150-word comparison article based on the research",
    expected_output="A cohesive 150-word article comparing REST and GraphQL",
    agent=writer
)

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    process=Process.sequential,
    verbose=True
)

result = crew.kickoff()
print(result)

Run it:

python isolated_tasks.py

Expected output: The writer produces a generic comparison because it never saw the researcher’s output. The tasks ran sequentially but shared nothing. This is the problem we’re solving.

Pattern 1: Direct Output Passing with context

The context parameter on Task accepts a list of upstream tasks whose outputs get injected into the downstream task’s prompt. This is the simplest pattern and covers 80% of use cases.

# context_passing.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
import os

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1
)

researcher = Agent(
    role="Research Analyst",
    goal="Find key facts about a topic",
    backstory="You extract concise, verifiable information.",
    llm=llm,
    verbose=True
)

writer = Agent(
    role="Technical Writer",
    goal="Write a summary from research",
    backstory="You transform raw facts into readable prose.",
    llm=llm,
    verbose=True
)

research_task = Task(
    description="Research the key differences between REST and GraphQL APIs. List 5 concrete differences.",
    expected_output="A bulleted list of 5 differences with brief explanations",
    agent=researcher
)

write_task = Task(
    description=(
        "Write a 150-word comparison article based on the research provided. "
        "Use the specific differences found by the researcher. Do not invent new ones."
    ),
    expected_output="A cohesive 150-word article comparing REST and GraphQL using the provided research",
    agent=writer,
    context=[research_task]  # <-- This wires the output
)

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    process=Process.sequential,
    verbose=True
)

result = crew.kickoff()
print(result)

Run it:

python context_passing.py

Expected output: The writer now references specific differences from the researcher’s output (e.g., “As the research noted, GraphQL allows clients to specify exact data requirements…”). The context parameter injects the upstream task’s raw output into the downstream agent’s prompt as a context block.

How context Works Internally

When you specify context=[task_a, task_b], CrewAI builds a prompt section like:

Context from previous tasks:
Task: Research the key differences...
Output: - REST uses multiple endpoints; GraphQL uses a single endpoint...
- REST over-fetches data; GraphQL prevents over-fetching...

The downstream agent sees this before its own task description. No parsing logic required — but you lose structure. The output is a string blob.

Pattern 2: Structured Output with Pydantic Models

String blobs break when downstream tasks need to extract specific fields. Use output_json or output_pydantic on the upstream task to enforce structure, then access typed fields in the downstream task.

# structured_context.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from typing import List
import os

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1
)

class Difference(BaseModel):
    aspect: str = Field(description="The category of difference (e.g., 'fetching', 'endpoints')")
    rest_behavior: str = Field(description="How REST handles this")
    graphql_behavior: str = Field(description="How GraphQL handles this")

class ResearchOutput(BaseModel):
    differences: List[Difference] = Field(description="List of 5 differences")
    summary: str = Field(description="One-sentence summary")

researcher = Agent(
    role="Research Analyst",
    goal="Find key facts about a topic",
    backstory="You extract concise, verifiable information in structured format.",
    llm=llm,
    verbose=True
)

writer = Agent(
    role="Technical Writer",
    goal="Write a summary from structured research",
    backstory="You transform structured data into readable prose.",
    llm=llm,
    verbose=True
)

research_task = Task(
    description="Research the key differences between REST and GraphQL APIs. Return structured data.",
    expected_output="A ResearchOutput object with 5 differences and a summary",
    agent=researcher,
    output_pydantic=ResearchOutput
)

write_task = Task(
    description=(
        "Write a 150-word comparison article using the structured research. "
        "Reference each difference by its 'aspect' field. "
        "The research is available as a ResearchOutput object with 'differences' list and 'summary'."
    ),
    expected_output="A cohesive 150-word article comparing REST and GraphQL",
    agent=writer,
    context=[research_task]
)

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, write_task],
    process=Process.sequential,
    verbose=True
)

result = crew.kickoff()
print(result)

Run it:

python structured_context.py

Expected output: The writer references structured fields like “In the ‘fetching’ aspect, REST over-fetches while GraphQL prevents over-fetching.” The Pydantic model guarantees the shape, so downstream prompts can reference differences[0].aspect reliably.

Accessing Structured Context in Code

Sometimes you need the parsed object in your own Python logic, not just the agent prompt. After kickoff(), each task’s output attribute holds the raw string. For Pydantic tasks, use output.pydantic:

result = crew.kickoff()

# Access the structured output programmatically
research_output = research_task.output.pydantic
print(f"Found {len(research_output.differences)} differences")
for diff in research_output.differences:
    print(f"  - {diff.aspect}: REST={diff.rest_behavior} | GraphQL={diff.graphql_behavior}")

This enables hybrid workflows: agents consume context via prompts, your orchestration code consumes it via typed objects.

Pattern 3: Shared Memory for Long-Running State

context passes output forward once. For state that accumulates across many tasks — or that multiple downstream tasks need — use CrewAI’s memory system. Enable it on the crew and give agents memory=True.

# shared_memory.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
import os

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1
)

# Shared memory requires a memory backend. CrewAI uses ChromaDB by default.
# pip install chromadb

fact_finder = Agent(
    role="Fact Finder",
    goal="Discover and store interesting facts about topics",
    backstory="You research topics and commit key facts to long-term memory.",
    llm=llm,
    verbose=True,
    memory=True  # <-- Enables memory for this agent
)

synthesizer = Agent(
    role="Synthesizer",
    goal="Connect facts across multiple research sessions",
    backstory="You retrieve stored facts and find patterns.",
    llm=llm,
    verbose=True,
    memory=True
)

research_task = Task(
    description="Research 3 surprising facts about the history of the Python programming language. Store each fact in memory with the tag 'python-history'.",
    expected_output="Confirmation that 3 facts were stored",
    agent=fact_finder
)

recall_task = Task(
    description="Retrieve all facts tagged 'python-history' from memory and write a connecting narrative showing how they relate.",
    expected_output="A narrative connecting the retrieved facts",
    agent=synthesizer
)

crew = Crew(
    agents=[fact_finder, synthesizer],
    tasks=[research_task, recall_task],
    process=Process.sequential,
    verbose=True,
    memory=True  # <-- Enables crew-level memory
)

result = crew.kickoff()
print(result)

Run it:

python shared_memory.py

Expected output: The synthesizer retrieves facts the finder stored, even though no explicit context link exists. Memory persists across crew runs if you reuse the same ChromaDB directory.

Memory Configuration Details

By default, CrewAI creates a ./memory directory with ChromaDB. For production, configure a persistent path:

from crewai.memory import LongTermMemory, ShortTermMemory, EntityMemory
from crewai.memory.storage import ChromaDBStorage

crew = Crew(
    agents=[fact_finder, synthesizer],
    tasks=[research_task, recall_task],
    process=Process.sequential,
    verbose=True,
    memory=True,
    long_term_memory=LongTermMemory(
        storage=ChromaDBStorage(path="./crew_memory/long_term")
    ),
    short_term_memory=ShortTermMemory(
        storage=ChromaDBStorage(path="./crew_memory/short_term")
    ),
    entity_memory=EntityMemory(
        storage=ChromaDBStorage(path="./crew_memory/entities")
    )
)

Three memory types exist:

  • Long-term: Persistent facts across sessions (ChromaDB)
  • Short-term: Current session context, auto-pruned
  • Entity: Tracks entities (people, concepts) and their attributes

Agents with memory=True can read/write all three. The memory parameter on Crew enables the system; the agent flag opts individual agents in.

Pattern 4: Callback-Driven State for Complex Orchestration

When you need deterministic state transitions — validation, branching, external API calls between tasks — use task callbacks. The callback parameter on Task receives the task output and lets you mutate shared state before the next task runs.

# callback_state.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
from typing import Dict, Any
import os
import json

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1
)

# Shared state container
class PipelineState:
    def __init__(self):
        self.data: Dict[str, Any] = {}
    
    def set(self, key: str, value: Any):
        self.data[key] = value
    
    def get(self, key: str, default=None):
        return self.data.get(key, default)

state = PipelineState()

def validate_research(output: str) -> str:
    """Callback: validate research output has 5 items, else retry."""
    lines = [l.strip() for l in output.split('\n') if l.strip().startswith(('-', '•', '*'))]
    if len(lines) < 5:
        raise ValueError(f"Expected 5 differences, got {len(lines)}. Output: {output}")
    state.set("validated_differences", lines)
    return output  # Return original output for context passing

def enrich_with_external_data(output: str) -> str:
    """Callback: simulate external enrichment."""
    diffs = state.get("validated_differences", [])
    enriched = []
    for d in diffs:
        # In reality, call an API here
        enriched.append(f"{d} [ENRICHED: verified via API]")
    state.set("enriched_differences", enriched)
    return output

researcher = Agent(
    role="Research Analyst",
    goal="Find key differences between REST and GraphQL",
    backstory="You produce exactly 5 bulleted differences.",
    llm=llm,
    verbose=True
)

writer = Agent(
    role="Technical Writer",
    goal="Write article from enriched differences",
    backstory="You use the enriched differences provided in context.",
    llm=llm,
    verbose=True
)

research_task = Task(
    description="Research 5 key differences between REST and GraphQL APIs. Output as a bulleted list only.",
    expected_output="Exactly 5 bullet points, each describing one difference",
    agent=researcher,
    callback=validate_research
)

enrich_task = Task(
    description="No-op task to trigger enrichment callback. The callback runs after research_task.",
    expected_output="Pass-through",
    agent=researcher,  # Reuse agent; callback does the work
    context=[research_task],
    callback=enrich_with_external_data
)

write_task = Task(
    description=(
        "Write a 150-word article using the enriched differences from context. "
        "Each difference has '[ENRICHED: verified via API]' suffix."
    ),
    expected_output="150-word article",
    agent=writer,
    context=[enrich_task]
)

crew = Crew(
    agents=[researcher, writer],
    tasks=[research_task, enrich_task, write_task],
    process=Process.sequential,
    verbose=True
)

result = crew.kickoff()
print(result)
print("\n--- Final State ---")
print(json.dumps(state.data, indent=2))

Run it:

python callback_state.py

Expected output: The validation callback ensures 5 differences exist before proceeding. The enrichment callback mutates state.data with enriched versions. The writer receives the enriched output via context=[enrich_task]. The final state dump shows both validated_differences and enriched_differences.

When to Use Callbacks vs. Context

Scenario Use
Downstream task needs upstream output in prompt context=[task]
Need typed, structured data in Python code output_pydantic + task.output.pydantic
State accumulates across many tasks, multiple consumers memory=True on crew and agents
Validation, transformation, external calls between tasks callback on task
Branching logic (skip task B if task A fails) callback raises exception or returns sentinel

Combining Patterns: A Production Template

Real pipelines mix these. Here’s a template that validates structured output, stores key facts in memory, and passes enriched context to writers.

# production_template.py
from crewai import Agent, Task, Crew, Process
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from typing import List, Optional
import os

llm = ChatOpenAI(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    base_url=os.getenv("OPENAI_API_BASE"),
    temperature=0.1
)

class Fact(BaseModel):
    claim: str
    source: str
    confidence: float = Field(ge=0.0, le=1.0)

class ResearchResult(BaseModel):
    topic: str
    facts: List[Fact]
    gaps: List[str] = Field(default_factory=list)

researcher = Agent(
    role="Senior Researcher",
    goal="Produce verified, structured research",
    backstory="You output ResearchResult with high-confidence facts only.",
    llm=llm,
    verbose=True,
    memory=True
)

fact_checker = Agent(
    role="Fact Checker",
    goal="Validate claims and flag low-confidence items",
    backstory="You review research and identify gaps.",
    llm=llm,
    verbose=True,
    memory=True
)

writer = Agent(
    role="Technical Writer",
    goal="Write accurate articles from verified research",
    backstory="You only write claims backed by high-confidence facts.",
    llm=llm,
    verbose=True,
    memory=True
)

def validate_research(output: str) -> str:
    # In practice, parse JSON and validate against ResearchResult
    # For demo, we trust the Pydantic output
    return output

research_task = Task(
    description="Research 'async vs sync Python for I/O workloads'. Return ResearchResult with 5 facts, each with claim, source, confidence.",
    expected_output="ResearchResult JSON",
    agent=researcher,
    output_pydantic=ResearchResult,
    callback=validate_research
)

check_task = Task(
    description=(
        "Review the research. Identify any facts with confidence < 0.7 as gaps. "
        "Output updated ResearchResult with gaps populated."
    ),
    expected_output="ResearchResult JSON with gaps field filled",
    agent=fact_checker,
    context=[research_task],
    output_pydantic=ResearchResult
)

write_task = Task(
    description=(
        "Write a 200-word technical article using ONLY facts with confidence >= 0.7. "
        "Cite sources inline. Note any gaps identified by the fact checker."
    ),
    expected_output="200-word article with inline citations",
    agent=writer,
    context=[check_task]
)

crew = Crew(
    agents=[researcher, fact_checker, writer],
    tasks=[research_task, check_task, write_task],
    process=Process.sequential,
    verbose=True,
    memory=True
)

result = crew.kickoff()
print(result)

# Programmatic access to final structured output
final_research = check_task.output.pydantic
print(f"\nTopic: {final_research.topic}")
print(f"High-confidence facts: {len([f for f in final_research.facts if f.confidence >= 0.7])}")
print(f"Gaps identified: {final_research.gaps}")

Run it:

python production_template.py

Expected output: A three-stage pipeline where research produces typed output, fact-checking enriches it with gaps, and writing consumes only high-confidence claims. Memory retains facts across runs if you re-execute the crew.

Common Pitfalls

1. Forgetting context on the downstream task The upstream task produces output regardless, but the downstream agent never sees it without context=[upstream_task].

2. Assuming context passes structured objects It passes the raw string output. Use output_pydantic on the upstream task and reference field names explicitly in the downstream description.

3. Memory growing unbounded Long-term memory persists forever. Implement a cleanup job or use short-term memory for session-scoped state.

4. Callbacks mutating output unexpectedly A callback’s return value becomes the task’s output for context purposes. Return the original output if you only want side effects.

5. Agent reuse with conflicting memory If two agents share memory=True but have different roles, they read/write the same ChromaDB collections. Use distinct crews or namespaced memory paths for isolation.

Summary

Pattern Use Case Complexity
context=[task] Linear pipelines, single consumer Low
output_pydantic + context Typed contracts between tasks Medium
memory=True Cross-session state, multiple consumers Medium
callback Validation, enrichment, branching High

Start with context. Add output_pydantic when string parsing hurts. Enable memory when state outlives a single crew run. Reach for callback when you need procedural control between tasks. Most production crews use all four.

Tagscrewaitask-designcontextworkflow

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All crewai agent roles & task design posts →