n4nAI

CrewAI vs AutoGen vs LangGraph for a coding agent

A practical comparison of CrewAI, AutoGen, and LangGraph for building coding agents, with code examples and architectural tradeoffs.

n4n Team4 min read893 words

Audio narration

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

Choosing between CrewAI, AutoGen, and LangGraph for a coding agent comes down to how much control you want over the execution graph versus how fast you need to ship. Each framework makes different bets on abstraction level, state management, and human-in-the-loop patterns. This guide walks through implementing the same coding task — generating a PR from a GitHub issue — in all three so you can see the differences in practice.

The task: issue-to-pr pipeline

We’ll build a minimal agent that:

  1. Fetches a GitHub issue
  2. Reads relevant repository files
  3. Generates a fix
  4. Opens a pull request

This exercises the core capabilities you need: tool use, multi-step reasoning, state persistence, and external API integration.

CrewAI: role-based crews with implicit flow

CrewAI structures work around agents (roles), tasks (assignments), and crews (orchestration). The flow is largely declarative — you define who does what, and the framework sequences tasks based on dependencies.

# crewai_coding_agent.py
from crewai import Agent, Task, Crew, Process
from crewai.tools import BaseTool
from github import Github
import os

class GitHubIssueTool(BaseTool):
    name: str = "fetch_issue"
    
    def _run(self, repo: str, issue_number: int) -> str:
        gh = Github(os.getenv("GITHUB_TOKEN"))
        repo_obj = gh.get_repo(repo)
        issue = repo_obj.get_issue(issue_number)
        return f"Title: {issue.title}\nBody: {issue.body}"

class GitHubFileTool(BaseTool):
    name: str = "read_file"
    
    def _run(self, repo: str, path: str, ref: str = "main") -> str:
        gh = Github(os.getenv("GITHUB_TOKEN"))
        repo_obj = gh.get_repo(repo)
        content = repo_obj.get_contents(path, ref=ref)
        return content.decoded_content.decode()

class GitHubPRTool(BaseTool):
    name: str = "create_pr"
    
    def _run(self, repo: str, title: str, body: str, branch: str, changes: dict) -> str:
        gh = Github(os.getenv("GITHUB_TOKEN"))
        repo_obj = gh.get_repo(repo)
        base = repo_obj.get_branch("main")
        new_branch = repo_obj.create_git_ref(f"refs/heads/{branch}", base.commit.sha)
        
        for path, content in changes.items():
            try:
                file = repo_obj.get_contents(path, ref=branch)
                repo_obj.update_file(path, f"Update {path}", content, file.sha, branch=branch)
            except:
                repo_obj.create_file(path, f"Create {path}", content, branch=branch)
        
        pr = repo_obj.create_pull(title=title, body=body, head=branch, base="main")
        return pr.html_url

# Define agents with specific roles
issue_analyst = Agent(
    role="Issue Analyst",
    goal="Understand the GitHub issue and identify required changes",
    backstory="You're a senior engineer who excels at reading issue reports and mapping them to codebase locations.",
    tools=[GitHubIssueTool(), GitHubFileTool()],
    verbose=True
)

code_generator = Agent(
    role="Code Generator",
    goal="Implement the fix based on the analysis",
    backstory="You write clean, tested code that solves the described problem.",
    tools=[GitHubFileTool()],
    verbose=True
)

pr_creator = Agent(
    role="PR Creator",
    goal="Create a well-documented pull request",
    backstory="You craft clear PR descriptions that help reviewers understand the change.",
    tools=[GitHubPRTool()],
    verbose=True
)

# Define tasks with explicit dependencies
analyze_task = Task(
    description="Fetch issue #{issue_number} from {repo} and identify files to modify",
    expected_output="List of file paths and description of required changes",
    agent=issue_analyst
)

generate_task = Task(
    description="Implement the fix in the identified files",
    expected_output="Dictionary mapping file paths to new file contents",
    agent=code_generator,
    context=[analyze_task]
)

pr_task = Task(
    description="Create a PR with the changes",
    expected_output="PR URL",
    agent=pr_creator,
    context=[generate_task]
)

crew = Crew(
    agents=[issue_analyst, code_generator, pr_creator],
    tasks=[analyze_task, generate_task, pr_task],
    process=Process.sequential,
    verbose=True
)

# Run
result = crew.kickoff(inputs={"repo": "owner/repo", "issue_number": 123})
print(result)

Where CrewAI shines: Rapid prototyping. The role-based mental model maps well to how teams actually work. You get reasonable defaults for task sequencing, and the context parameter handles data passing between tasks automatically.

Pitfalls: The implicit flow becomes opaque fast. Debugging why a task produced garbage output means digging through verbose logs. The framework assumes sequential or hierarchical processes — custom control flow (loops, conditionals, parallel branches) requires fighting the abstraction. State is ephemeral; if the process crashes mid-run, you restart from the beginning.

AutoGen: conversation-driven with explicit agents

AutoGen models everything as conversations between agents. You define agent types (AssistantAgent, UserProxyAgent, etc.) and register reply functions. The framework handles message passing, but you control the conversation pattern.

# autogen_coding_agent.py
import autogen
from github import Github
import os
import json

config_list = [{"model": "gpt-4", "api_key": os.getenv("OPENAI_API_KEY")}]

# Tools as functions the LLM can call
def fetch_issue(repo: str, issue_number: int) -> str:
    gh = Github(os.getenv("GITHUB_TOKEN"))
    repo_obj = gh.get_repo(repo)
    issue = repo_obj.get_issue(issue_number)
    return json.dumps({"title": issue.title, "body": issue.body})

def read_file(repo: str, path: str, ref: str = "main") -> str:
    gh = Github(os.getenv("GITHUB_TOKEN"))
    repo_obj = gh.get_repo(repo)
    content = repo_obj.get_contents(path, ref=ref)
    return content.decoded_content.decode()

def create_pr(repo: str, title: str, body: str, branch: str, changes: dict) -> str:
    gh = Github(os.getenv("GITHUB_TOKEN"))
    repo_obj = gh.get_repo(repo)
    base = repo_obj.get_branch("main")
    repo_obj.create_git_ref(f"refs/heads/{branch}", base.commit.sha)
    
    for path, content in changes.items():
        try:
            file = repo_obj.get_contents(path, ref=branch)
            repo_obj.update_file(path, f"Update {path}", content, file.sha, branch=branch)
        except:
            repo_obj.create_file(path, f"Create {path}", content, branch=branch)
    
    pr = repo_obj.create_pull(title=title, body=body, head=branch, base="main")
    return pr.html_url

# Define agents
analyst = autogen.AssistantAgent(
    name="IssueAnalyst",
    llm_config={"config_list": config_list},
    system_message="""You analyze GitHub issues. Use fetch_issue to get details, 
    then read_file to examine relevant code. Output a JSON plan with file paths and changes needed."""
)

coder = autogen.AssistantAgent(
    name="CodeGenerator",
    llm_config={"config_list": config_list},
    system_message="""You implement fixes. Given a plan, read_file to see current code, 
    then output a JSON object mapping file paths to new contents."""
)

pr_agent = autogen.AssistantAgent(
    name="PRCreator",
    llm_config={"config_list": config_list},
    system_message="""You create PRs. Given the changes, call create_pr with appropriate arguments."""
)

# User proxy executes tools and manages flow
user_proxy = autogen.UserProxyAgent(
    name="UserProxy",
    human_input_mode="NEVER",
    max_consecutive_auto_reply=10,
    code_execution_config=False,
    function_map={
        "fetch_issue": fetch_issue,
        "read_file": read_file,
        "create_pr": create_pr,
    }
)

# Register functions for each agent
for agent in [analyst, coder, pr_agent]:
    for func in [fetch_issue, read_file, create_pr]:
        agent.register_for_llm(name=func.__name__, description=func.__doc__)(func)

# Define the conversation flow
def run_pipeline(repo: str, issue_number: int):
    # Step 1: Analyst creates plan
    user_proxy.initiate_chat(
        analyst,
        message=f"Analyze issue #{issue_number} in {repo}. Output JSON plan."
    )
    # In practice, you'd capture the last message as the plan
    # This is simplified - real code needs message parsing
    
    # Step 2: Coder implements
    user_proxy.initiate_chat(
        coder,
        message="Implement the plan. Output JSON with file changes."
    )
    
    # Step 3: PR agent creates PR
    user_proxy.initiate_chat(
        pr_agent,
        message="Create the PR with these changes."
    )

run_pipeline("owner/repo", 123)

Where AutoGen shines: Flexible conversation patterns. Want a code-review loop where two agents debate a solution? That’s a few lines of register_reply logic. The function-calling integration is first-class — tools are just Python functions. Group chats (GroupChat + GroupChatManager) handle multi-party discussions naturally.

Pitfalls: You build the orchestration yourself. The example above is simplified; a production version needs explicit message parsing, error handling, and state management between chat rounds. No built-in persistence — if the process dies, you lose the conversation history. Token usage can explode in group chats with verbose agents.

LangGraph: explicit state machines with checkpoints

LangGraph models workflows as state graphs — nodes (functions) and edges (transitions) operating on a shared state object. This is the closest to writing raw application code, with the framework providing checkpointing, streaming, and human-in-the-loop primitives.

# langgraph_coding_agent.py
from typing import TypedDict, Annotated, List, Dict
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
from github import Github
import os
import json

class AgentState(TypedDict):
    repo: str
    issue_number: int
    issue_data: dict
    file_contents: Dict[str, str]
    plan: List[dict]
    changes: Dict[str, str]
    pr_url: str
    error: str
    current_step: str

# Tools as plain functions
def fetch_issue(state: AgentState) -> AgentState:
    gh = Github(os.getenv("GITHUB_TOKEN"))
    repo_obj = gh.get_repo(state["repo"])
    issue = repo_obj.get_issue(state["issue_number"])
    return {
        **state,
        "issue_data": {"title": issue.title, "body": issue.body},
        "current_step": "analyze"
    }

def read_files(state: AgentState) -> AgentState:
    gh = Github(os.getenv("GITHUB_TOKEN"))
    repo_obj = gh.get_repo(state["repo"])
    contents = {}
    for path in state["plan"]:
        try:
            file = repo_obj.get_contents(path["path"])
            contents[path["path"]] = file.decoded_content.decode()
        except Exception as e:
            contents[path["path"]] = f"ERROR: {e}"
    return {**state, "file_contents": contents, "current_step": "generate"}

def generate_fix(state: AgentState) -> AgentState:
    # In practice, call LLM here with state["issue_data"], state["file_contents"]
    # This is a placeholder for the actual generation logic
    changes = {}
    for path, content in state["file_contents"].items():
        if not content.startswith("ERROR"):
            changes[path] = content + "\n# Fixed by agent"
    return {**state, "changes": changes, "current_step": "create_pr"}

def create_pr(state: AgentState) -> AgentState:
    gh = Github(os.getenv("GITHUB_TOKEN"))
    repo_obj = gh.get_repo(state["repo"])
    branch = f"fix-issue-{state['issue_number']}"
    base = repo_obj.get_branch("main")
    repo_obj.create_git_ref(f"refs/heads/{branch}", base.commit.sha)
    
    for path, content in state["changes"].items():
        try:
            file = repo_obj.get_contents(path, ref=branch)
            repo_obj.update_file(path, f"Fix for issue #{state['issue_number']}", content, file.sha, branch=branch)
        except:
            repo_obj.create_file(path, f"Fix for issue #{state['issue_number']}", content, branch=branch)
    
    pr = repo_obj.create_pull(
        title=f"Fix: {state['issue_data']['title']}",
        body=f"Automated fix for issue #{state['issue_number']}",
        head=branch,
        base="main"
    )
    return {**state, "pr_url": pr.html_url, "current_step": "done"}

def should_continue(state: AgentState) -> str:
    if state.get("error"):
        return "error"
    step = state["current_step"]
    if step == "analyze":
        return "read_files"
    elif step == "read_files":
        return "generate_fix"
    elif step == "generate_fix":
        return "create_pr"
    return END

# Build the graph
workflow = StateGraph(AgentState)

workflow.add_node("fetch_issue", fetch_issue)
workflow.add_node("read_files", read_files)
workflow.add_node("generate_fix", generate_fix)
workflow.add_node("create_pr", create_pr)

workflow.set_entry_point("fetch_issue")

workflow.add_conditional_edges(
    "fetch_issue",
    should_continue,
    {"read_files": "read_files", "error": END, END: END}
)
workflow.add_conditional_edges(
    "read_files",
    should_continue,
    {"generate_fix": "generate_fix", "error": END, END: END}
)
workflow.add_conditional_edges(
    "generate_fix",
    should_continue,
    {"create_pr": "create_pr", "error": END, END: END}
)
workflow.add_edge("create_pr", END)

# Compile with checkpointing
checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
app = workflow.compile(checkpointer=checkpointer)

# Run with thread_id for persistence
config = {"configurable": {"thread_id": "issue-123"}}
initial_state = {
    "repo": "owner/repo",
    "issue_number": 123,
    "issue_data": {},
    "file_contents": {},
    "plan": [{"path": "src/main.py"}, {"path": "tests/test_main.py"}],
    "changes": {},
    "pr_url": "",
    "error": "",
    "current_step": "start"
}

# Execute
for event in app.stream(initial_state, config=config):
    print(event)

# Resume after crash - same thread_id
# for event in app.stream(None, config=config):  # None continues from checkpoint
#     print(event)

Where LangGraph shines: Production readiness. Checkpointing (SqliteSaver, PostgresSaver) means you can resume after failures, inspect intermediate state, and implement human-in-the-loop by pausing at specific nodes. The graph structure is explicit — you can visualize it, test nodes in isolation, and add conditional edges for retries, fallbacks, or parallel execution. Streaming (app.stream) gives token-by-token output for UIs.

Pitfalls: Verbose. You write the orchestration code yourself. The TypedDict state schema becomes a maintenance surface as the agent grows. No built-in agent abstractions — you’re composing functions, not configuring personas. The learning curve is steeper if you’re coming from higher-level frameworks.

Comparison matrix

Dimension CrewAI AutoGen LangGraph
Abstraction level High (roles, tasks) Medium (agents, conversations) Low (nodes, edges, state)
Control flow Implicit (sequential/hierarchical) Explicit (conversation patterns) Explicit (graph edges)
State persistence None built-in None built-in First-class (checkpointers)
Human-in-the-loop Limited Via UserProxyAgent Native (interrupt/resume)
Debugging Verbose logs Message history State inspection + time travel
Parallel execution Limited GroupChat Native (fan-out/fan-in)
Learning curve Low Medium High
Best for Quick prototypes, role-based workflows Multi-agent debates, flexible conversations Production systems, complex control flow

Common pitfalls across all three

Tool schema drift: All three frameworks rely on the LLM calling functions correctly. When your tool signatures change, the prompts don’t auto-update. Version your tool schemas and validate inputs at runtime.

# Add this to every tool function
from pydantic import BaseModel, ValidationError

class ReadFileArgs(BaseModel):
    repo: str
    path: str
    ref: str = "main"

def read_file_validated(args: dict) -> str:
    try:
        validated = ReadFileArgs(**args)
    except ValidationError as e:
        return f"Invalid arguments: {e}"
    return read_file(validated.repo, validated.path, validated.ref)

Context window exhaustion: Coding agents read many files. Implement a summarization step or use a vector store for repository context rather than stuffing everything into the prompt.

Rate limit handling: None of these frameworks handle provider rate limits automatically. If you’re routing through a gateway like n4n.ai, you get automatic fallback across 240+ models when a provider degrades. Otherwise, implement exponential backoff at the tool level.

Secrets management: The examples use os.getenv. In production, use a secrets manager and inject credentials at runtime — never bake tokens into container images.

Decision framework

Choose CrewAI if: You’re building a prototype this week, the workflow is roughly linear, and your team thinks in roles/tasks. Accept that you’ll rewrite in LangGraph when the prototype becomes the product.

Choose AutoGen if: Your agent needs dynamic conversation patterns — code review debates, planning discussions, or human-in-the-loop approval flows that don’t map to a fixed graph. Invest in building your own orchestration layer.

Choose LangGraph if: You’re building for production from day one. You need durability (resume after crash), observability (inspect state at any node), or complex control flow (parallel file reads, conditional retries, human checkpoints). The upfront investment pays off in maintainability.

A note on model routing

Regardless of framework, your agent will call LLMs heavily. Hardcoding a single provider creates a single point of failure. Route through a gateway that handles fallback, usage metering, and cache-control forwarding so your agent stays up when one provider degrades.


Start with the framework that matches your current constraint: speed (CrewAI), flexibility (AutoGen), or correctness (LangGraph). Migrate when the pain of your current choice exceeds the cost of switching.

Tagscrewaiautogenlanggraphcoding-agent

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 multi-agent framework showdown: crewai vs autogen vs langgraph posts →