← Back to Blog

What You'll Build

If you've been searching for Claude API tutorials that actually produce something useful, you're in the right place. By the end of this guide, you'll have a working 4-agent Python pipeline that researches a topic, generates a structured outline, writes a full blog post, and optimizes it for SEO — all automatically.

Each agent talks to the next using Claude's tool use feature, passing structured data through a clean orchestration loop. You'll walk away with real, runnable code and a system you can drop into your own projects.

📦 Full Source Code
The complete working code for all four agents is included step by step in the sections below. Every snippet builds on the last, so by Step 6 you'll have the entire pipeline assembled and ready to run. No pseudocode — everything here executes.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic SDK installed (pip install anthropic)
  • python-dotenv for environment variable management (pip install python-dotenv)
  • A .env file with your ANTHROPIC_API_KEY set

Step 1: Set Up Your Claude API Credentials and Python Environment

First, let's get your environment wired up correctly. Create a project folder and drop in a .env file with your API key. This keeps credentials out of your source code, which matters once you start sharing or deploying this.

.env
ANTHROPIC_API_KEY=sk-ant-your-key-here

Now install the required packages and create your project structure. I keep everything in one file for tutorials like this, but feel free to split it into modules as the project grows.

setup.sh
mkdir seo_pipeline
cd seo_pipeline
pip install anthropic python-dotenv
touch main.py agents.py tools.py

Here's the base config file that every agent will import. It loads your key and initializes the shared Anthropic client.

config.py
import os
from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

# Shared client instance used by all agents
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
MODEL = "claude-sonnet-4-6"

Step 2: Create the Research Agent for Topic Gathering

The research agent is your pipeline's starting point. It takes a seed topic and returns structured research data — primary keywords, related subtopics, target audience notes, and competitor angles. I've modeled this as a class so each agent in the pipeline follows the same pattern.

Notice the tool definition — Claude uses this schema to understand what kind of structured output it should produce. This is what makes the handoff between agents clean.

agents.py (ResearchAgent)
import json
from config import client, MODEL


class ResearchAgent:
    """
    Agent 1: Gathers topic research and keyword data.
    Returns structured JSON consumed by the OutlineAgent.
    """

    def __init__(self):
        self.tools = [
            {
                "name": "compile_research",
                "description": (
                    "Compile SEO research for a given topic including primary keywords, "
                    "secondary keywords, target audience, key subtopics, and competitor angles."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "topic": {
                            "type": "string",
                            "description": "The main topic being researched"
                        },
                        "primary_keyword": {
                            "type": "string",
                            "description": "The single most important keyword phrase"
                        },
                        "secondary_keywords": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "List of 4-6 supporting keyword phrases"
                        },
                        "target_audience": {
                            "type": "string",
                            "description": "Description of who this content is for"
                        },
                        "subtopics": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "5-7 subtopics to cover in the article"
                        },
                        "search_intent": {
                            "type": "string",
                            "enum": ["informational", "navigational", "transactional", "commercial"],
                            "description": "The primary search intent behind the keyword"
                        },
                        "word_count_target": {
                            "type": "integer",
                            "description": "Recommended article word count based on topic complexity"
                        }
                    },
                    "required": [
                        "topic", "primary_keyword", "secondary_keywords",
                        "target_audience", "subtopics", "search_intent", "word_count_target"
                    ]
                }
            }
        ]

    def run(self, topic: str) -> dict:
        """Run the research agent on a topic and return structured research data."""
        print(f"\n[ResearchAgent] Analyzing topic: '{topic}'")

        response = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            tools=self.tools,
            tool_choice={"type": "tool", "name": "compile_research"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Research the following topic for an SEO blog post: '{topic}'. "
                        "Identify the best primary keyword, 4-6 secondary keywords, "
                        "the target audience, 5-7 key subtopics to cover, the search intent, "
                        "and an appropriate word count target. Use the compile_research tool."
                    )
                }
            ]
        )

        # Extract the tool use block from the response
        for block in response.content:
            if block.type == "tool_use":
                research_data = block.input
                print(f"[ResearchAgent] Primary keyword identified: '{research_data['primary_keyword']}'")
                return research_data

        raise ValueError("ResearchAgent did not return a tool_use block.")
💡 Why tool_choice forced?
Setting tool_choice={"type": "tool", "name": "compile_research"} forces Claude to always call that specific tool. Without this, Claude might just reply with text — and then the next agent has nothing structured to work with. Always force tool use when you need guaranteed structured output.

Step 3: Build the Outline Generator Agent

The outline agent takes the research data from Step 2 and turns it into a hierarchical article structure. It returns a list of sections, each with a heading and bullet-point talking points. The content writer agent uses this directly — no ambiguity.

agents.py (OutlineAgent)
class OutlineAgent:
    """
    Agent 2: Generates a structured article outline from research data.
    Returns a list of sections consumed by the ContentWriterAgent.
    """

    def __init__(self):
        self.tools = [
            {
                "name": "generate_outline",
                "description": "Generate a structured blog post outline from research data.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "title": {
                            "type": "string",
                            "description": "The SEO-optimized article title"
                        },
                        "meta_description": {
                            "type": "string",
                            "description": "150-160 character meta description for the article"
                        },
                        "sections": {
                            "type": "array",
                            "description": "Ordered list of article sections",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "heading": {
                                        "type": "string",
                                        "description": "H2 or H3 section heading"
                                    },
                                    "heading_level": {
                                        "type": "string",
                                        "enum": ["h2", "h3"],
                                        "description": "Heading hierarchy level"
                                    },
                                    "talking_points": {
                                        "type": "array",
                                        "items": {"type": "string"},
                                        "description": "3-5 key points to cover in this section"
                                    }
                                },
                                "required": ["heading", "heading_level", "talking_points"]
                            }
                        },
                        "estimated_sections": {
                            "type": "integer",
                            "description": "Total number of sections in the outline"
                        }
                    },
                    "required": ["title", "meta_description", "sections", "estimated_sections"]
                }
            }
        ]

    def run(self, research_data: dict) -> dict:
        """Generate an outline from research data."""
        print(f"\n[OutlineAgent] Building outline for: '{research_data['primary_keyword']}'")

        # Serialize research data cleanly for the prompt
        research_summary = json.dumps(research_data, indent=2)

        response = client.messages.create(
            model=MODEL,
            max_tokens=2048,
            tools=self.tools,
            tool_choice={"type": "tool", "name": "generate_outline"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Create a detailed blog post outline using this research data:\n\n"
                        f"{research_summary}\n\n"
                        "Structure the outline to match the target word count. "
                        "Include an intro section, all key subtopics as sections, "
                        "a FAQ section, and a conclusion. "
                        "Make the title include the primary keyword naturally. "
                        "Use the generate_outline tool."
                    )
                }
            ]
        )

        for block in response.content:
            if block.type == "tool_use":
                outline_data = block.input
                print(f"[OutlineAgent] Outline created: {outline_data['estimated_sections']} sections")
                return outline_data

        raise ValueError("OutlineAgent did not return a tool_use block.")

Step 4: Implement the Content Writer Agent

This is where the actual article gets written. The content writer agent loops through every section in the outline and writes the prose — in markdown, with proper heading hierarchy. I use a multi-turn conversation here so Claude maintains context across all sections instead of treating each one as isolated.

This is one of the more important design decisions in the whole pipeline. Without conversation history, the tone and style drifts between sections. Multi-turn keeps it coherent.

agents.py (ContentWriterAgent)
class ContentWriterAgent:
    """
    Agent 3: Writes full markdown article content section by section.
    Uses multi-turn conversation to maintain style consistency.
    """

    def __init__(self):
        # System prompt defines the writer's voice and constraints
        self.system_prompt = (
            "You are an expert content writer specializing in technical blog posts. "
            "Write in a clear, conversational tone — first person where appropriate. "
            "Use short paragraphs (2-3 sentences max). Avoid buzzwords and jargon. "
            "Format output as clean markdown with proper heading hierarchy. "
            "Each section should flow naturally into the next."
        )

    def run(self, outline_data: dict, research_data: dict) -> str:
        """Write the full article using multi-turn conversation."""
        print(f"\n[ContentWriterAgent] Writing article: '{outline_data['title']}'")

        conversation_history = []
        article_sections = []

        # Initial context-setting message
        intro_message = (
            f"I need you to write a blog post titled: '{outline_data['title']}'\n\n"
            f"Target audience: {research_data['target_audience']}\n"
            f"Primary keyword to include naturally: {research_data['primary_keyword']}\n"
            f"Secondary keywords to weave in: {', '.join(research_data['secondary_keywords'])}\n"
            f"Target word count: approximately {research_data['word_count_target']} words\n\n"
            "I'll give you one section at a time. Write only that section's content "
            "in markdown format. Start with the article title as an H1."
        )

        conversation_history.append({"role": "user", "content": intro_message})

        # Write each section in sequence, maintaining conversation history
        for i, section in enumerate(outline_data["sections"]):
            talking_points_text = "\n".join(f"- {pt}" for pt in section["talking_points"])
            section_prompt = (
                f"Write section {i + 1} of {len(outline_data['sections'])}:\n\n"
                f"Heading ({section['heading_level']}): {section['heading']}\n"
                f"Cover these points:\n{talking_points_text}\n\n"
                "Write the full section content in markdown. "
                "Do not include the heading itself — just the body content. "
                "Keep paragraphs to 2-3 sentences."
            )

            if i == 0:
                # First section: append to the intro message exchange
                conversation_history.append({"role": "user", "content": section_prompt})
            else:
                # Subsequent sections: add as new user turn after assistant's last reply
                conversation_history.append({"role": "user", "content": section_prompt})

            response = client.messages.create(
                model=MODEL,
                max_tokens=1500,
                system=self.system_prompt,
                messages=conversation_history
            )

            section_content = response.content[0].text

            # Add the assistant's response to history so next section has context
            conversation_history.append({"role": "assistant", "content": section_content})

            heading_prefix = "##" if section["heading_level"] == "h2" else "###"
            article_sections.append(f"{heading_prefix} {section['heading']}\n\n{section_content}")

            print(f"[ContentWriterAgent] ✓ Section {i + 1}/{len(outline_data['sections'])}: {section['heading']}")

        full_article = f"# {outline_data['title']}\n\n" + "\n\n".join(article_sections)
        print(f"[ContentWriterAgent] Article complete. Approx {len(full_article.split())} words.")
        return full_article

Step 5: Add the SEO Optimizer Agent

The SEO optimizer is the final quality gate. It takes the finished article and the original research data, then returns a structured optimization report with keyword density scores, missing keyword flags, readability notes, and a revised meta description if the original needs work.

Importantly, it also returns an optimized_content field — the actual rewritten article with improvements applied. You get both the report and the fixed article in one shot.

agents.py (SEOOptimizerAgent)
class SEOOptimizerAgent:
    """
    Agent 4: Analyzes and optimizes the article for SEO.
    Returns an optimization report plus the revised article content.
    """

    def __init__(self):
        self.tools = [
            {
                "name": "optimize_content",
                "description": (
                    "Analyze article content for SEO quality and return an optimization "
                    "report along with the improved article content."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "seo_score": {
                            "type": "integer",
                            "description": "Overall SEO quality score from 0-100"
                        },
                        "primary_keyword_density": {
                            "type": "number",
                            "description": "Primary keyword density as a percentage (target: 0.5-1.5%)"
                        },
                        "missing_keywords": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "Secondary keywords not found in the article"
                        },
                        "issues_found": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "List of SEO issues identified in the article"
                        },
                        "improvements_made": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "List of specific improvements made to the content"
                        },
                        "revised_meta_description": {
                            "type": "string",
                            "description": "Optimized meta description, 150-160 characters"
                        },
                        "optimized_content": {
                            "type": "string",
                            "description": "The full revised article content in markdown"
                        },
                        "word_count": {
                            "type": "integer",
                            "description": "Final word count of the optimized article"
                        }
                    },
                    "required": [
                        "seo_score", "primary_keyword_density", "missing_keywords",
                        "issues_found", "improvements_made", "revised_meta_description",
                        "optimized_content", "word_count"
                    ]
                }
            }
        ]

    def run(self, article_content: str, research_data: dict, outline_data: dict) -> dict:
        """Optimize the article for SEO and return a full report."""
        print(f"\n[SEOOptimizerAgent] Running SEO analysis...")

        keywords_list = ", ".join(research_data["secondary_keywords"])

        response = client.messages.create(
            model=MODEL,
            max_tokens=8096,
            tools=self.tools,
            tool_choice={"type": "tool", "name": "optimize_content"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Analyze and optimize this article for SEO.\n\n"
                        f"PRIMARY KEYWORD: {research_data['primary_keyword']}\n"
                        f"SECONDARY KEYWORDS: {keywords_list}\n"
                        f"SEARCH INTENT: {research_data['search_intent']}\n"
                        f"TARGET WORD COUNT: {research_data['word_count_target']}\n"
                        f"CURRENT META DESCRIPTION: {outline_data['meta_description']}\n\n"
                        f"ARTICLE CONTENT:\n{article_content}\n\n"
                        "Check keyword density, ensure natural keyword placement, "
                        "verify heading structure, improve the introduction if it doesn't "
                        "hook the reader immediately, and return the fully optimized article. "
                        "Use the optimize_content tool."
                    )
                }
            ]
        )

        for block in response.content:
            if block.type == "tool_use":
                seo_report = block.input
                print(f"[SEOOptimizerAgent] SEO Score: {seo_report['seo_score']}/100")
                print(f"[SEOOptimizerAgent] Final word count: {seo_report['word_count']}")
                if seo_report["missing_keywords"]:
                    print(f"[SEOOptimizerAgent] Missing keywords fixed: {seo_report['missing_keywords']}")
                return seo_report

        raise ValueError("SEOOptimizerAgent did not return a tool_use block.")

Step 6: Connect Agents with Tool Use and Multi-Turn Conversations

Now we wire everything together in the main orchestrator. This is the file you actually run. It instantiates all four agents, passes data between them in sequence, and saves the final output to a markdown file.

The orchestrator is deliberately simple — its job is just to manage the handoffs and log what's happening. All the real work stays inside the agent classes.

main.py
import json
import os
from datetime import datetime
from agents import ResearchAgent, OutlineAgent, ContentWriterAgent, SEOOptimizerAgent


class SEOContentPipeline:
    """
    Main orchestrator that coordinates all 4 agents in sequence.
    Handles data handoffs and saves final output.
    """

    def __init__(self):
        self.research_agent = ResearchAgent()
        self.outline_agent = OutlineAgent()
        self.content_writer = ContentWriterAgent()
        self.seo_optimizer = SEOOptimizerAgent()

    def run(self, topic: str, output_dir: str = "output") -> dict:
        """
        Run the full pipeline: research → outline → write → optimize.
        Returns a dict with all intermediate and final outputs.
        """
        print(f"\n{'='*60}")
        print(f"  SEO Content Pipeline Starting")
        print(f"  Topic: {topic}")
        print(f"{'='*60}")

        # Agent 1: Research
        research_data = self.research_agent.run(topic)

        # Agent 2: Outline
        outline_data = self.outline_agent.run(research_data)

        # Agent 3: Write
        article_content = self.content_writer.run(outline_data, research_data)

        # Agent 4: SEO Optimize
        seo_report = self.seo_optimizer.run(article_content, research_data, outline_data)