← Back to Blog

If you've searched for a how to build AI agents tutorial that actually produces something useful, you've probably run into a lot of toy examples that stop at "hello world." This one doesn't. By the end, you'll have a working multi-agent SEO content pipeline that researches keywords, writes optimized articles, runs quality checks, and hands off clean copy — all orchestrated automatically with the Claude API.

The problem most developers hit is that single-prompt content generation is brittle. One agent doing everything produces mediocre output. Breaking the work into specialized agents — each with a clear job — is what separates a prototype from something you'd actually put in production.

This tutorial walks you through every line of code I use when I build these systems for clients at Naples AI. Nothing is abstracted away. You'll see exactly how the agents talk to each other.

What You'll Build

You'll build a five-agent Python pipeline powered by the Anthropic SDK. A Content Strategy Agent defines the angle, a Keyword Research Agent surfaces relevant terms, a Content Writer Agent drafts the article with streaming output, and an Editor & QA Agent checks quality before anything ships.

An Orchestrator Agent sits on top of all of them, routing tasks and passing context between agents using a simple tool-calling pattern. You run one command, feed it a topic, and get a structured, SEO-ready article out the other end.

The whole thing runs locally in Python and is production-ready — meaning you can drop it into a cron job, connect it to a CMS API, or wrap it in a FastAPI endpoint with minimal changes.

Prerequisites

  • Python 3.10 or higher
  • An Anthropic API key (get one at console.anthropic.com)
  • anthropic Python SDK installed (pip install anthropic)
  • Basic familiarity with Python classes and async concepts
  • A terminal and a code editor — that's genuinely all you need
📦 Full Source Code Note: The complete, working source code is built step by step in the sections below. Each snippet builds on the last. By Step 7, you'll have the entire pipeline in one place. If you want to skip ahead and grab the final assembled version, scroll to Step 7 — but I'd recommend reading the steps in order the first time so you understand what each agent is actually doing.

Step 1: Set Up Your Claude API Client and Environment

First, get your environment squared away. Create a project folder, set up a virtual environment, and install the Anthropic SDK. You'll also want python-dotenv so your API key never ends up in source control.

Terminal:
mkdir seo-agent-pipeline && cd seo-agent-pipeline
python -m venv venv && source venv/bin/activate
pip install anthropic python-dotenv

Create a .env file in your project root and add your key. Never hardcode it in the Python files.

.env
ANTHROPIC_API_KEY=your_api_key_here

Now create the base client module. Every agent in the pipeline imports from here, so you're only initializing the client once.

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

load_dotenv()

# Single shared client instance used by all agents
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

MODEL = "claude-sonnet-4-5"

def get_client() -> Anthropic:
    return client

Quick sanity check — run this in your terminal to confirm the connection works before writing any agent code:

test_connection.py
from client import get_client, MODEL

client = get_client()

response = client.messages.create(
    model=MODEL,
    max_tokens=50,
    messages=[{"role": "user", "content": "Say 'API connected' and nothing else."}]
)

print(response.content[0].text)
# Expected output: API connected

If you see API connected, you're good. If you get an AuthenticationError, double-check that your .env file is in the same directory you're running the script from.

Step 2: Define Your Content Strategy Agent

The Content Strategy Agent is the first agent in the chain. It takes a raw topic and turns it into a structured content brief — target audience, article angle, recommended format, and a list of talking points. Everything downstream depends on this brief being solid.

I keep this agent intentionally simple: one system prompt, one user message, one structured response. No tool calls here — just clean text generation with a predictable output format the other agents can parse.

agents/strategy_agent.py
from dataclasses import dataclass
from client import get_client, MODEL

client = get_client()

@dataclass
class ContentBrief:
    topic: str
    target_audience: str
    article_angle: str
    recommended_format: str
    talking_points: list[str]
    word_count_target: int

STRATEGY_SYSTEM_PROMPT = """You are a senior content strategist specializing in SEO content for business websites.
Your job is to analyze a topic and produce a structured content brief.
Always return your response in this exact format:

TARGET AUDIENCE: [one sentence describing the reader]
ARTICLE ANGLE: [one sentence describing the unique perspective]
RECOMMENDED FORMAT: [e.g., tutorial, listicle, case study, guide]
WORD COUNT TARGET: [number between 1200 and 3500]
TALKING POINTS:
- [point 1]
- [point 2]
- [point 3]
- [point 4]
- [point 5]

Be specific. Generic briefs produce generic content."""

def run_strategy_agent(topic: str) -> ContentBrief:
    """Takes a raw topic string and returns a structured ContentBrief."""
    response = client.messages.create(
        model=MODEL,
        max_tokens=800,
        system=STRATEGY_SYSTEM_PROMPT,
        messages=[
            {"role": "user", "content": f"Create a content brief for this topic: {topic}"}
        ]
    )

    raw = response.content[0].text
    lines = raw.strip().split("\n")

    # Parse each labeled field from the formatted response
    parsed = {}
    talking_points = []
    in_points = False

    for line in lines:
        line = line.strip()
        if line.startswith("TARGET AUDIENCE:"):
            parsed["target_audience"] = line.replace("TARGET AUDIENCE:", "").strip()
        elif line.startswith("ARTICLE ANGLE:"):
            parsed["article_angle"] = line.replace("ARTICLE ANGLE:", "").strip()
        elif line.startswith("RECOMMENDED FORMAT:"):
            parsed["recommended_format"] = line.replace("RECOMMENDED FORMAT:", "").strip()
        elif line.startswith("WORD COUNT TARGET:"):
            raw_count = line.replace("WORD COUNT TARGET:", "").strip()
            parsed["word_count_target"] = int("".join(filter(str.isdigit, raw_count)))
        elif line == "TALKING POINTS:":
            in_points = True
        elif in_points and line.startswith("-"):
            talking_points.append(line.lstrip("- ").strip())

    return ContentBrief(
        topic=topic,
        target_audience=parsed.get("target_audience", ""),
        article_angle=parsed.get("article_angle", ""),
        recommended_format=parsed.get("recommended_format", ""),
        talking_points=talking_points,
        word_count_target=parsed.get("word_count_target", 1500)
    )

The ContentBrief dataclass is what gets passed to every downstream agent. Structured data beats raw strings every time — you'll thank yourself later when you're debugging why the writer agent missed a talking point.

Step 3: Create the Keyword Research Agent

The Keyword Research Agent takes the content brief and generates a targeted keyword list. In a real production system you'd hit a third-party API like DataForSEO or Semrush here. For this tutorial, Claude acts as the research layer — and honestly, for initial content planning it does a surprisingly good job.

This is also where I introduce tool definitions. The agent uses a tool called analyze_keyword_opportunity to return structured keyword data. This forces the model to output consistent JSON instead of freeform text, which makes parsing reliable.

agents/keyword_agent.py
import json
from dataclasses import dataclass
from client import get_client, MODEL
from agents.strategy_agent import ContentBrief

client = get_client()

@dataclass
class KeywordResearch:
    primary_keyword: str
    secondary_keywords: list[str]
    lsi_keywords: list[str]
    search_intent: str
    recommended_title: str

# Tool definition that forces structured keyword output from the model
KEYWORD_TOOL = {
    "name": "analyze_keyword_opportunity",
    "description": "Analyze keyword opportunities for a content piece and return structured keyword data.",
    "input_schema": {
        "type": "object",
        "properties": {
            "primary_keyword": {
                "type": "string",
                "description": "The single best primary keyword phrase for this content"
            },
            "secondary_keywords": {
                "type": "array",
                "items": {"type": "string"},
                "description": "3-5 supporting keyword phrases"
            },
            "lsi_keywords": {
                "type": "array",
                "items": {"type": "string"},
                "description": "5-8 latent semantic indexing keywords related to the topic"
            },
            "search_intent": {
                "type": "string",
                "enum": ["informational", "navigational", "transactional", "commercial"],
                "description": "The primary search intent behind this keyword cluster"
            },
            "recommended_title": {
                "type": "string",
                "description": "An SEO-optimized article title under 65 characters"
            }
        },
        "required": ["primary_keyword", "secondary_keywords", "lsi_keywords", "search_intent", "recommended_title"]
    }
}

KEYWORD_SYSTEM_PROMPT = """You are an SEO keyword research specialist.
Given a content brief, identify the best keyword opportunities.
Always use the analyze_keyword_opportunity tool to return your findings.
Focus on keywords with clear search intent and realistic ranking potential for a business website."""

def run_keyword_agent(brief: ContentBrief) -> KeywordResearch:
    """Takes a ContentBrief and returns structured KeywordResearch."""
    user_message = f"""Perform keyword research for this content brief:

Topic: {brief.topic}
Target Audience: {brief.target_audience}
Article Angle: {brief.article_angle}
Talking Points: {", ".join(brief.talking_points)}

Use the analyze_keyword_opportunity tool to return your keyword recommendations."""

    response = client.messages.create(
        model=MODEL,
        max_tokens=1000,
        system=KEYWORD_SYSTEM_PROMPT,
        tools=[KEYWORD_TOOL],
        tool_choice={"type": "any"},  # Force tool use — we need structured output
        messages=[{"role": "user", "content": user_message}]
    )

    # Extract the tool call result from the response
    for block in response.content:
        if block.type == "tool_use" and block.name == "analyze_keyword_opportunity":
            data = block.input
            return KeywordResearch(
                primary_keyword=data["primary_keyword"],
                secondary_keywords=data["secondary_keywords"],
                lsi_keywords=data["lsi_keywords"],
                search_intent=data["search_intent"],
                recommended_title=data["recommended_title"]
            )

    raise ValueError("Keyword agent did not return tool use response. Check tool_choice setting.")
💡 Why tool_choice: "any"? Setting tool_choice to {"type": "any"} forces Claude to use a tool rather than responding in plain text. Without this, the model might just write a paragraph about keywords instead of returning structured JSON you can actually use programmatically.

Step 4: Build the Content Writer Agent

The Content Writer Agent is where the bulk of the work happens. It takes the content brief and keyword research, then generates the full article. This is the one agent in the pipeline where I use streaming — long-form content generation can take 30+ seconds, and streaming lets you see output in real time instead of staring at a blank terminal.

The system prompt is detailed here by design. The more specific you are about structure, tone, and keyword placement, the less editing the QA agent has to do downstream.

agents/writer_agent.py
from dataclasses import dataclass
from client import get_client, MODEL
from agents.strategy_agent import ContentBrief
from agents.keyword_agent import KeywordResearch

client = get_client()

@dataclass
class DraftArticle:
    title: str
    content: str
    word_count: int
    primary_keyword: str

WRITER_SYSTEM_PROMPT = """You are an expert SEO content writer for business websites.
Write clear, useful, direct content. No fluff, no filler sentences.

STRUCTURE RULES:
- Start with a hook that addresses the reader's problem directly
- Use H2 subheadings every 300-400 words
- Include the primary keyword in the first 100 words and in at least 2 subheadings
- Weave secondary keywords naturally — never force them
- End with a clear next step or call to action

TONE RULES:
- Write like a knowledgeable colleague, not a textbook
- Short paragraphs: 2-3 sentences maximum
- Active voice throughout
- Specific over vague — use numbers, examples, and concrete details"""

def run_writer_agent(brief: ContentBrief, keywords: KeywordResearch, stream: bool = True) -> DraftArticle:
    """Generates a full draft article using streaming for real-time output."""

    user_message = f"""Write a complete SEO article using the details below.

TITLE: {keywords.recommended_title}
PRIMARY KEYWORD: {keywords.primary_keyword}
SECONDARY KEYWORDS: {", ".join(keywords.secondary_keywords)}
LSI KEYWORDS: {", ".join(keywords.lsi_keywords)}
TARGET AUDIENCE: {brief.target_audience}
ARTICLE ANGLE: {brief.article_angle}
FORMAT: {brief.recommended_format}
TARGET WORD COUNT: {brief.word_count_target}
TALKING POINTS TO COVER:
{chr(10).join(f"- {p}" for p in brief.talking_points)}

Write the complete article now. Include all headings and the full body text."""

    full_content = ""

    if stream:
        print("\n--- Content Writer Agent: Streaming Output ---\n")
        # Stream the response and print tokens as they arrive
        with client.messages.stream(
            model=MODEL,
            max_tokens=4096,
            system=WRITER_SYSTEM_PROMPT,
            messages=[{"role": "user", "content": user_message}]
        ) as stream_obj:
            for text_chunk in stream_obj.text_stream:
                print(text_chunk, end="", flush=True)
                full_content += text_chunk
        print("\n\n--- Streaming Complete ---\n")
    else:
        # Non-streaming fallback for batch/scheduled runs
        response = client.messages.create(
            model=MODEL,
            max_tokens=4096,
            system=WRITER_SYSTEM_PROMPT,
            messages=[{"role": "user", "content": user_message}]
        )
        full_content = response.content[0].text

    word_count = len(full_content.split())

    return DraftArticle(
        title=keywords.recommended_title,
        content=full_content,
        word_count=word_count,
        primary_keyword=keywords.primary_keyword
    )

The stream=True flag is on by default. If you're running this in a scheduled job at 3am where no one's watching, flip it to False for slightly cleaner logging. Both paths return the same DraftArticle object.

Step 5: Implement the Editor & QA Agent

Most AI content pipelines skip this step and they shouldn't. The Editor & QA Agent reviews the draft against a checklist: keyword density, readability, structure, factual hedging, and a few other signals that separate publishable content from content that needs another pass.

This agent uses another tool definition to return a structured quality report. The orchestrator uses the approved flag to decide whether to pass the content forward or send it back for revision.

agents/editor_agent.py
from dataclasses import dataclass
from client import get_client, MODEL
from agents.writer_agent import DraftArticle

client = get_client()

@dataclass
class QAReport:
    approved: bool
    quality_score: int  # 0-100
    keyword_density_ok: bool
    structure_ok: bool
    readability_ok: bool
    issues_found: list[str]
    suggested_edits: list[str]
    final_content: str  # May be revised or identical to draft

# Tool definition for structured QA output
QA_TOOL = {
    "name": "submit_quality_report",
    "description": "Submit a quality assurance report for a drafted article.",
    "input_schema": {
        "type": "object",
        "properties": {
            "approved": {
                "type": "boolean",
                "description": "True if the article meets publication standards, False if it needs revision"
            },
            "quality_score": {
                "type": "integer",
                "description": "Overall quality score from 0 to 100"
            },
            "keyword_density_ok": {
                "type": "boolean",
                "description": "True if primary keyword appears 2-4 times per 1000 words"
            },
            "structure_ok": {
                "type": "boolean",
                "description": "True if the article has proper H2 subheadings and paragraph structure"
            },
            "readability_ok": {
                "type": "boolean",
                "description": "True if paragraphs are short and sentences are clear"
            },
            "issues_found": {
                "type": "array",
                "items": {"type": "string"},
                "description": "List of specific issues found in the content"
            },
            "suggested_edits": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Actionable suggestions to improve the content"
            },
            "revised_content": {
                "type": "string",
                "description": "The full article text with any inline edits applied. Return the original if no edits were needed."
            }
        },
        "required": ["approved", "quality_score", "keyword_density_ok", "structure_ok",
                     "readability_ok", "issues_found", "suggested_edits", "revised_content"]
    }
}

EDITOR_SYSTEM_PROMPT = """You are a senior content editor and SEO specialist.
Review the article carefully against these standards:

QUALITY CHECKLIST:
1. Primary keyword appears in the first 100 words
2. Primary keyword density is 1-3% (roughly 2-4 times per 1000 words)
3. At least 2 H2 subheadings include the primary keyword or close variants
4. No paragraph exceeds 4 sentences
5. No filler phrases like "In today's world" or "It's important to note"
6. Article has a clear introduction, body, and conclusion
7. Concrete specifics — numbers, examples, or steps — appear throughout

If you find issues, fix them directly in the revised_content field.
Always use the submit_quality_report tool."""

def run_editor_agent(draft: DraftArticle) -> QAReport:
    """Reviews a draft article and returns a structured QA report with optional revisions."""

    user_message = f"""Please review and QA this article.

PRIMARY KEYWORD: {draft.primary_keyword}
WORD COUNT: {draft.word_count}

ARTICLE:
{draft.content}

Use the submit_quality_report tool to return your assessment and any revised content."""

    response = client.messages.create(
        model=MODEL,
        max_tokens=5000,
        system=EDITOR_SYSTEM_PROMPT,
        tools=[QA_TOOL],
        tool_choice={"type": "any"},
        messages=[{"role": "user", "content": user_message}]
    )

    for block in response.content:
        if block.type == "tool_use" and block.name == "submit_quality_report":
            data = block.input
            return QAReport(
                approved=data["approved"],
                quality_score=data["quality_score"],
                keyword_density_ok=data["keyword_density_ok"],
                structure_ok=data["structure_ok"],
                readability_ok=data["readability_ok"],
                issues_found=data.get("issues_found", []),
                suggested_edits=data.get("suggested_edits", []),
                final_content=data.get("revised_content", draft.content)
            )

    raise ValueError("Editor agent did not return a tool use response.")

Step 6: Create the Orchestrator Agent

The Orchestrator is the brains of the operation. It doesn't write content — it manages the pipeline. It routes tasks to the right agents in the right order, passes outputs as