CrewAI turns LLM calls into structured multi-agent workflows, but the framework assumes you already have model access and credentials wired up. This tutorial walks through the complete setup: installing dependencies, managing API keys securely, selecting models via n4n.ai’s unified endpoint, and writing a minimal YAML config that drives a working crew. You’ll end with a runnable example that produces structured output you can inspect.
Prerequisites
- Python 3.10 or newer
- An OpenAI-compatible API key (we’ll use n4n.ai’s single endpoint for 240+ models)
- Basic familiarity with YAML and Pydantic
Create a fresh virtual environment and install the core packages:
python -m venv .venv
source .venv/bin/activate
pip install crewai crewai-tools pyyaml python-dotenv
Managing API keys
Never hardcode secrets. Use a .env file at the project root and load it with python-dotenv before any CrewAI imports.
# .env
N4N_API_KEY=sk-your-key-here
N4N_BASE_URL=https://api.n4n.ai/v1
# config/loader.py
from pathlib import Path
from dotenv import load_dotenv
def load_env() -> None:
env_path = Path(__file__).resolve().parents[1] / ".env"
load_dotenv(dotenv_path=env_path, override=True)
Call load_env() as the very first line in your entrypoint script. This keeps credentials out of source control and works identically in local dev and containerized deployments.
Model configuration via YAML
CrewAI reads model parameters from a config/llms.yaml file when you pass llm_config to an Agent. Define one or more named profiles so you can swap models without touching code.
# config/llms.yaml
default:
model: "openai/gpt-4o-mini"
base_url: "https://api.n4n.ai/v1"
api_key: "${N4N_API_KEY}"
temperature: 0.2
max_tokens: 2048
reasoning:
model: "openai/o1-mini"
base_url: "https://api.n4n.ai/v1"
api_key: "${N4N_API_KEY}"
temperature: 1.0
max_tokens: 4096
The ${N4N_API_KEY} placeholder is resolved by CrewAI’s config loader when the environment variable is set. Using a single base URL means you can switch between GPT-4o, Claude, Llama, or any of the 240+ models without changing application code — only the model string.
Create a small utility to load the YAML and hand it to agents:
# config/models.py
import yaml
from pathlib import Path
from crewai import LLM
CONFIG_PATH = Path(__file__).with_name("llms.yaml")
def get_llm(profile: str = "default") -> LLM:
with CONFIG_PATH.open() as f:
cfg = yaml.safe_load(f)[profile]
return LLM(**cfg)
Building a minimal crew
A crew needs agents, tasks, and a process. Start with two agents: a researcher that gathers facts and a writer that formats them. Each agent gets its own LLM profile.
# crew/agents.py
from crewai import Agent
from config.models import get_llm
researcher = Agent(
role="Tech researcher",
goal="Find accurate, up-to-date information on a given topic",
backstory="You specialize in distilling technical documentation into concise summaries.",
llm=get_llm("default"),
verbose=True,
allow_delegation=False,
)
writer = Agent(
role="Technical writer",
goal="Produce a clear, structured markdown report from research notes",
backstory="You turn raw bullets into polished documentation engineers trust.",
llm=get_llm("reasoning"),
verbose=True,
allow_delegation=False,
)
Define tasks with explicit output expectations. The output_json schema forces structured results you can parse downstream.
# crew/tasks.py
from crewai import Task
from crew.agents import researcher, writer
from pydantic import BaseModel, Field
class ResearchOutput(BaseModel):
topic: str
key_points: list[str] = Field(min_items=3, max_items=7)
sources: list[str]
research_task = Task(
description="Research {topic} and extract 5-7 key technical points with source URLs.",
expected_output="A JSON object matching the ResearchOutput schema.",
agent=researcher,
output_json=ResearchOutput,
)
write_task = Task(
description="Convert the research JSON into a markdown report with sections: Overview, Key Points, Sources.",
expected_output="A complete markdown document ready to publish.",
agent=writer,
context=[research_task],
)
Wire everything together in a crew definition. The Process.sequential default passes each task’s output to the next.
# crew/crew.py
from crewai import Crew, Process
from crew.agents import researcher, writer
from crew.tasks import research_task, write_task
tech_crew = Crew(
agents=[researcher, writer],
tasks=[research_task, write_task],
process=Process.sequential,
verbose=True,
)
Entrypoint and execution
The main script loads environment variables, kicks off the crew, and prints the final markdown.
# main.py
from config.loader import load_env
load_env() # must be first
from crew.crew import tech_crew
if __name__ == "__main__":
result = tech_crew.kickoff(inputs={"topic": "CrewAI memory systems"})
print(result.raw)
Run it:
python main.py
Expected output (truncated for brevity):
# CrewAI Memory Systems: Technical Overview
## Overview
CrewAI provides two memory layers: short-term (conversation history) and long-term (vector-backed persistence)...
## Key Points
- Short-term memory is scoped to a single crew execution and stored in-memory.
- Long-term memory uses ChromaDB by default; configurable via `memory_config`.
- Agents access memory through the `memory` attribute on `TaskOutput`.
- Memory can be shared across crews by passing the same `Memory` instance.
- Embedding model defaults to `text-embedding-3-small`; override in config.
## Sources
- https://docs.crewai.com/concepts/memory
- https://github.com/joaomdmoura/crewai/blob/main/src/crewai/memory
Verifying token usage
CrewAI exposes token counts on the CrewOutput object. Add a small wrapper to log them:
# main.py (add after kickoff)
usage = result.token_usage
print(f"Prompt tokens: {usage.prompt_tokens}, Completion tokens: {usage.completion_tokens}, Total: {usage.total_tokens}")
Typical output:
Prompt tokens: 1,842, Completion tokens: 1,105, Total: 2,947
These numbers come directly from the provider response headers. When routing through n4n.ai, the gateway forwards x-ratelimit-remaining and x-request-id headers so you can correlate usage with provider dashboards.
Common pitfalls
Missing environment variable — CrewAI raises ValueError: API key not found if .env isn’t loaded before the first LLM instantiation. Ensure load_env() runs at module import time in your entrypoint.
Model name mismatch — The model string must exactly match the provider’s identifier (e.g., openai/gpt-4o-mini, anthropic/claude-3-5-sonnet). Check the n4n.ai model catalog for the canonical list.
YAML indentation errors — Use two spaces, no tabs. Run yamllint config/llms.yaml in CI to catch drift.
Output schema validation failure — If the LLM returns malformed JSON, the task retries up to max_retry_limit (default 3). Increase temperature slightly or add few-shot examples to the task description.
Next steps
- Add a third agent with
allow_delegation=Trueto orchestrate sub-tasks. - Persist long-term memory by configuring
memory_configin the crew constructor. - Wrap the crew in a FastAPI endpoint for production serving.
- Set up Langfuse or LangSmith tracing by adding the callback handler to each agent’s
llmconfig.
You now have a reproducible, config-driven CrewAI setup that separates credentials, model selection, and agent logic — ready to scale from prototype to production.