← Back to Blog

If you've searched for a real, working how to build AI agents tutorial, you already know most examples are either too shallow to be useful or too abstract to actually run. This guide closes that gap. You're going to build a multi-agent automation system using the Anthropic Claude API — with a real orchestrator, real tool calls, and real inter-agent communication.

By the end, you'll have code you can actually drop into a project. No fluff, no pseudocode.

What You'll Build

You're building a multi-agent system where a central orchestrator agent receives a high-level task and delegates subtasks to specialized worker agents using Claude's tool use feature. The orchestrator decides which agent handles what, collects results, and returns a unified response.

The example task is a business research workflow: the system takes a company name, delegates to a research agent and a summarization agent, then returns a structured report. It's simple enough to follow but real enough to adapt for production use.

📦 Full Source Code: The complete working code is built step by step through this tutorial. Every snippet below is production-ready Python that runs against the live Claude API. Copy each section in order and you'll have a working multi-agent system by the end.

Prerequisites

  • Python 3.10 or higher
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic SDK installed (pip install anthropic)
  • A .env file or environment variable for your API key

Step 1: Setting Up the Claude SDK and Environment

Start by installing the SDK and wiring up your API key. I keep credentials in a .env file using python-dotenv so nothing sensitive ends up in source control. Run pip install anthropic python-dotenv if you haven't already.

setup.py
import os
import anthropic
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Initialize the Anthropic client
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Confirm the connection works with a simple ping
def test_connection():
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=64,
        messages=[{"role": "user", "content": "Say 'connected' and nothing else."}]
    )
    print(response.content[0].text)

if __name__ == "__main__":
    test_connection()

Run that file. If you see connected printed to the terminal, you're good. If you get an AuthenticationError, double-check that your .env file is in the same directory and your key starts with sk-ant-.

Step 2: Defining Agent Roles and Tool Specifications

In a multi-agent system, the orchestrator uses tools to "call" other agents. Each tool definition is a JSON schema that tells Claude what arguments to pass. Think of it as writing a function signature that Claude can invoke.

We're defining two worker agents: a research agent that gathers raw information and a summarization agent that condenses that information into a structured report.

tools.py
import os
import anthropic
from dotenv import load_dotenv

load_dotenv()
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Tool definitions tell the orchestrator what agents are available
# and what arguments each agent expects
AGENT_TOOLS = [
    {
        "name": "research_agent",
        "description": (
            "Calls a specialized research agent to gather detailed factual information "
            "about a company, topic, or entity. Returns raw research notes."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The specific research question or company name to investigate."
                },
                "depth": {
                    "type": "string",
                    "enum": ["brief", "standard", "deep"],
                    "description": "How thorough the research should be."
                }
            },
            "required": ["query", "depth"]
        }
    },
    {
        "name": "summarization_agent",
        "description": (
            "Calls a specialized summarization agent to distill raw research notes "
            "into a clean, structured business report."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "raw_content": {
                    "type": "string",
                    "description": "The raw research notes or content to summarize."
                },
                "format": {
                    "type": "string",
                    "enum": ["bullet_points", "paragraph", "executive_summary"],
                    "description": "The desired output format for the summary."
                }
            },
            "required": ["raw_content", "format"]
        }
    }
]

if __name__ == "__main__":
    print(f"Loaded {len(AGENT_TOOLS)} agent tools:")
    for tool in AGENT_TOOLS:
        print(f"  - {tool['name']}: {tool['description'][:60]}...")

Run this and you'll see both tools listed with their descriptions. The input_schema block is standard JSON Schema — Claude uses it to know exactly what arguments to generate when it decides to call one of these agents.

Step 3: Creating the Main Orchestration Agent

The orchestrator is the brain of the system. It receives the user's request, decides which agents to call and in what order, and manages the overall workflow. Here's the class with full model setup.

orchestrator.py
import os
import anthropic
from dotenv import load_dotenv
from tools import AGENT_TOOLS

load_dotenv()

class OrchestratorAgent:
    """
    Central coordination agent that delegates tasks to specialized worker agents.
    Uses Claude's tool use feature to route subtasks intelligently.
    """

    # Model is set once at the class level for easy updates
    MODEL = "claude-sonnet-4-6"
    MAX_TOKENS = 4096
    SYSTEM_PROMPT = """You are a senior research coordinator. Your job is to:
1. Receive a business research request from the user
2. Use the research_agent tool to gather raw information
3. Use the summarization_agent tool to turn that raw information into a clean report
4. Return the final formatted report to the user

Always call research_agent first, then summarization_agent with the results.
Be methodical and thorough. Do not skip steps."""

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.conversation_history = []

    def run(self, user_request: str) -> str:
        """
        Entry point for the orchestrator. Takes a plain-English request
        and returns a completed research report.
        """
        print(f"\n[Orchestrator] Received request: {user_request}")

        # Start the conversation with the user's request
        self.conversation_history = [
            {"role": "user", "content": user_request}
        ]

        # Hand off to the execution loop
        return self._execute()

    def _execute(self) -> str:
        """
        Internal execution method. Sends the conversation to Claude
        and returns the final text response after all tool calls resolve.
        """
        while True:
            response = self.client.messages.create(
                model=self.MODEL,
                max_tokens=self.MAX_TOKENS,
                system=self.SYSTEM_PROMPT,
                tools=AGENT_TOOLS,
                messages=self.conversation_history
            )

            print(f"[Orchestrator] Stop reason: {response.stop_reason}")

            # If Claude is done and has no more tool calls, return the final text
            if response.stop_reason == "end_turn":
                final_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    "No response generated."
                )
                return final_text

            # If Claude wants to use tools, process them
            if response.stop_reason == "tool_use":
                # Add Claude's response (with tool calls) to history
                self.conversation_history.append({
                    "role": "assistant",
                    "content": response.content
                })
                # Process tool calls and continue the loop
                self._handle_tool_calls(response.content)
                continue

            # Catch any unexpected stop reasons
            break

        return "Orchestration ended unexpectedly."

    def _handle_tool_calls(self, content_blocks):
        """
        Processes all tool_use blocks in Claude's response.
        Calls the appropriate worker agent for each tool request.
        """
        tool_results = []

        for block in content_blocks:
            if block.type == "tool_use":
                print(f"[Orchestrator] Delegating to: {block.name} | Input: {block.input}")
                result = self._dispatch_to_agent(block.name, block.input)

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result
                })

        # Add all tool results back to the conversation so Claude can continue
        self.conversation_history.append({
            "role": "user",
            "content": tool_results
        })

    def _dispatch_to_agent(self, agent_name: str, agent_input: dict) -> str:
        """
        Routes a tool call to the correct worker agent function.
        Returns the agent's response as a plain string.
        """
        if agent_name == "research_agent":
            from agents import run_research_agent
            return run_research_agent(agent_input["query"], agent_input["depth"])

        if agent_name == "summarization_agent":
            from agents import run_summarization_agent
            return run_summarization_agent(agent_input["raw_content"], agent_input["format"])

        return f"Error: Unknown agent '{agent_name}'"


if __name__ == "__main__":
    orchestrator = OrchestratorAgent()
    result = orchestrator.run("Research the company Anthropic and give me an executive summary.")
    print("\n" + "="*60)
    print("FINAL REPORT:")
    print("="*60)
    print(result)

Step 4: Implementing Inter-Agent Communication

Worker agents are just Claude API calls with specialized system prompts. The key is formatting the messages correctly and parsing the response as a clean string so the orchestrator can pass it back as a tool result. Here's what that looks like in practice.

agents.py
import os
import anthropic
from dotenv import load_dotenv

load_dotenv()

# Each worker agent shares the same client but uses a different system prompt
_client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"


def _format_agent_message(role: str, content: str) -> dict:
    """
    Helper to format a message dict for the Claude messages API.
    Keeps message construction consistent across all agents.
    """
    return {"role": role, "content": content}


def _parse_agent_response(response) -> str:
    """
    Extracts plain text from a Claude API response object.
    Returns a fallback string if no text content is found.
    """
    for block in response.content:
        if hasattr(block, "text"):
            return block.text.strip()
    return "Agent returned no text content."


def run_research_agent(query: str, depth: str) -> str:
    """
    Specialized agent for gathering research on a topic.
    Depth controls how comprehensive the output is.
    """
    depth_instructions = {
        "brief": "Write 2-3 sentences covering only the most essential facts.",
        "standard": "Write 3-5 paragraphs covering background, products/services, and key facts.",
        "deep": "Write a comprehensive deep-dive covering history, products, leadership, financials, and market position."
    }

    system_prompt = f"""You are a professional business research analyst.
Your job is to gather and present factual information clearly and objectively.
{depth_instructions.get(depth, depth_instructions['standard'])}
Focus on accuracy. If you're uncertain about a fact, say so clearly."""

    # Format the outgoing message for this agent
    messages = [
        _format_agent_message("user", f"Research the following: {query}")
    ]

    print(f"  [Research Agent] Running query: '{query}' at depth: {depth}")

    response = _client.messages.create(
        model=MODEL,
        max_tokens=2048,
        system=system_prompt,
        messages=messages
    )

    # Parse and return the response as a plain string
    result = _parse_agent_response(response)
    print(f"  [Research Agent] Returned {len(result)} characters of research.")
    return result


def run_summarization_agent(raw_content: str, format: str) -> str:
    """
    Specialized agent for condensing research into a structured report.
    Format controls the output style.
    """
    format_instructions = {
        "bullet_points": "Format the output as clean bullet points grouped by category.",
        "paragraph": "Format the output as flowing prose paragraphs.",
        "executive_summary": (
            "Format as a professional executive summary with sections: "
            "Overview, Key Facts, and Takeaways."
        )
    }

    system_prompt = f"""You are a professional business writer specializing in executive communications.
Your job is to take raw research notes and produce a polished, readable report.
{format_instructions.get(format, format_instructions['executive_summary'])}
Be concise. Remove redundancy. Keep every sentence purposeful."""

    # Format the incoming raw content as the user message
    messages = [
        _format_agent_message(
            "user",
            f"Summarize the following research content:\n\n{raw_content}"
        )
    ]

    print(f"  [Summarization Agent] Summarizing {len(raw_content)} characters in '{format}' format.")

    response = _client.messages.create(
        model=MODEL,
        max_tokens=1024,
        system=system_prompt,
        messages=messages
    )

    result = _parse_agent_response(response)
    print(f"  [Summarization Agent] Returned {len(result)} characters.")
    return result


if __name__ == "__main__":
    # Quick standalone test for each agent
    research = run_research_agent("Anthropic AI company", "standard")
    print("\n--- Research Output ---")
    print(research[:300], "...")

    summary = run_summarization_agent(research, "executive_summary")
    print("\n--- Summary Output ---")
    print(summary)
⚠️ Important: The tool_use_id in each tool result must match the id field from Claude's original tool call. If these IDs don't match, the API will throw a validation error. The orchestrator's _handle_tool_calls method handles this automatically using block.id.

Step 5: Building the Execution Loop with Tool Use

The execution loop is where everything connects. Claude runs, decides it needs a tool, we call the tool, return the result, and Claude runs again — until it has everything it needs to write the final response. Here's the complete runnable entry point that ties every file together.

main.py
import os
from dotenv import load_dotenv
from orchestrator import OrchestratorAgent

load_dotenv()

def main():
    """
    Main entry point for the multi-agent research system.
    Demonstrates the full orchestration loop with tool use.
    """
    print("="*60)
    print("Naples AI — Multi-Agent Research System")
    print("="*60)

    # Initialize the orchestrator
    orchestrator = OrchestratorAgent()

    # Define the tasks to run — swap these for real-world use cases
    tasks = [
        "Research the company Anthropic and give me an executive summary report.",
    ]

    for i, task in enumerate(tasks, 1):
        print(f"\n[Task {i}] {task}")
        print("-" * 60)

        result = orchestrator.run(task)

        print("\n" + "="*60)
        print(f"COMPLETED REPORT — Task {i}")
        print("="*60)
        print(result)
        print()

if __name__ == "__main__":
    main()

Run python main.py and you'll see the full execution trace in your terminal. Here's a trimmed sample of what the output looks like:

Sample Output
============================================================
Naples AI — Multi-Agent Research System
============================================================

[Task 1] Research the company Anthropic and give me an executive summary report.
------------------------------------------------------------

[Orchestrator] Received request: Research the company Anthropic...
[Orchestrator] Stop reason: tool_use
[Orchestrator] Delegating to: research_agent | Input: {'query': 'Anthropic AI company', 'depth': 'standard'}
  [Research Agent] Running query: 'Anthropic AI company' at depth: standard
  [Research Agent] Returned 1842 characters of research.
[Orchestrator] Stop reason: tool_use
[Orchestrator] Delegating to: summarization_agent | Input: {'format': 'executive_summary', 'raw_content': '...'}
  [Summarization Agent] Summarizing 1842 characters in 'executive_summary' format.
  [Summarization Agent] Returned 612 characters.
[Orchestrator] Stop reason: end_turn

============================================================
COMPLETED REPORT — Task 1
============================================================
**Executive Summary: Anthropic**

**Overview**
Anthropic is an AI safety company founded in 2021 by former OpenAI researchers...

**Key Facts**
- Founded by Dario Amodei, Daniela Amodei, and others from OpenAI
- Flagship model: Claude, optimized for safety and helpfulness
- Raised over $7 billion in funding as of 2024
- Headquartered in San Francisco, CA

**Takeaways**
Anthropic is a leading safety-focused AI lab competing directly with OpenAI and Google DeepMind...

How It Works: Agent Coordination and Tool Delegation

Here's the plain-English version of what's happening under the hood. The orchestrator sends Claude the user's request along with the list of available tools. Claude reads the task, recognizes it needs to gather information first, and responds with a tool_use block instead of a text response.

Your code intercepts that tool call, runs the appropriate worker agent (which is just another Claude API call with a specialized system prompt), and sends the result back to the orchestrator as a tool_result message. The orchestrator then calls Claude again with the updated conversation — including the research output — and Claude decides whether to call another tool or write the final response.

This loop continues until Claude's stop_reason is end_turn, which means it's done delegating and is ready to deliver the finished output. The whole pattern is sometimes called an agentic loop, and it scales cleanly to dozens of specialized agents.

💡 Key insight: Worker agents don't need to know about each other. They just receive input, do their job, and return a string. All the coordination logic lives in the orchestrator. This makes the system easy to extend — adding a new agent is just adding a new tool definition and a new function in agents.py.

Common Errors and Fixes

Error 1: tool_use_id Does Not Match Any Tool Use Block

anthropic.BadRequestError: tool_use_id 'toolu_01XYZ' does not match any tool use block

This happens when you manually hardcode a tool result ID instead of pulling it directly from block.id. Fix it by always using the id attribute from the original tool call block, exactly as shown in _handle_tool_calls.

# Wrong — hardcoded ID will not match
tool_results.append({"type": "tool_result", "tool_use_id": "my-custom-id", "content": result})

# Correct — use the ID from the block Claude returned
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})

Error 2: Messages Must Alternate Between User and Assistant Roles

anthropic.BadRequestError: messages: roles must alternate between "user" and "assistant"

This fires when you add two consecutive messages with the same role. It commonly happens when you forget to append Claude's assistant response before adding your tool results. The fix is to append Claude's response to conversation_history before you add the tool results — which the orchestrator's _execute method already handles in the correct order.

Error 3: AttributeError