If you've ever tried to scale content production without sacrificing quality, you already know the problem. Writing one good SEO blog post takes hours. Writing fifty takes a team — or a smarter system. That's exactly what this tutorial solves.
I'm going to show you how to build a production-ready multi-agent SEO content pipeline using Python and the Claude API. This is the same kind of agentic AI workflow automation we build for real clients here at Naples AI, and by the end of this tutorial you'll have working code you can run yourself.
1. What You'll Build: A 3-Agent SEO Content System
You'll build a Python pipeline where three specialized Claude agents collaborate to produce a complete, SEO-optimized blog post from a single topic prompt. Each agent has a defined role, and they hand off structured context to each other through an orchestrator loop.
Here's what the finished system does: you pass in a topic like "Naples Florida luxury real estate market," and within seconds you get a research brief, an SEO-optimized draft, and a reviewed final article — all generated automatically. The whole thing runs as a single Python script with about 250 lines of clean, readable code.
The three agents are:
- Researcher Agent — generates a structured content brief with target keywords, audience pain points, and key talking points
- SEO Optimizer Agent — writes the full blog post using the research brief, applying on-page SEO rules
- Quality Reviewer Agent — evaluates the draft against a scoring rubric and returns a final pass/fail with specific revision notes
2. Prerequisites: Python, Anthropic SDK, API Key
Before you start, make sure you have the following ready:
- Python 3.9 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- The
anthropicPython SDK installed (pip install anthropic) python-dotenvfor environment variable management (pip install python-dotenv)- Basic comfort with Python classes and functions
You don't need any prior experience with multi-agent systems. I'll walk through every part of the code and explain why each piece is built the way it is.
The complete, working source code for this pipeline is built step by step in the sections below. Every snippet is production-ready and syntactically correct — just copy each section in order and you'll have the full working system by Step 5. No pseudocode, no placeholders.
3. Full Source Code Repository
The complete pipeline is split across five logical files that we'll build together: config.py, researcher_agent.py, seo_optimizer_agent.py, quality_reviewer_agent.py, and orchestrator.py. Each file is self-contained and imports cleanly from the others.
At the end of Step 5, you'll also see a full end-to-end example that generates a real estate blog post for a Naples, Florida audience — which is a real use case we run for clients in the local market.
4. Step 1: Set Up Your Claude API Client & Environment
Start by creating a .env file in your project root with your API key. Then we'll build the shared config that all three agents will use.
ANTHROPIC_API_KEY=your_api_key_here
import os
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv()
# Shared model identifier used by all agents in the pipeline
MODEL = "claude-sonnet-4-5"
# Single Anthropic client instance shared across all agents
client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
MAX_TOKENS = 4096Keeping the client in a shared config file means you only initialize it once. Every agent imports from here, so if you ever swap models or rotate API keys, you change it in one place.
Always define your model string as a constant, not a hardcoded string inside each function call. It's one of those small prompt engineering best practices that saves you a painful find-and-replace session later when a new model version drops.
5. Step 2: Define the Content Researcher Agent
The Researcher Agent's job is to take a raw topic and return a structured research brief. It uses a detailed system prompt to force consistent JSON output — that's what makes agent handoff reliable.
Here's the full class with its research tool definition and execution logic:
researcher_agent.pyimport json
from config import client, MODEL, MAX_TOKENS
class ResearcherAgent:
"""
Agent 1: Generates a structured SEO research brief from a topic string.
Outputs JSON with keywords, audience profile, pain points, and content angle.
"""
def __init__(self):
self.system_prompt = """You are an expert SEO content strategist with 10 years of experience
in keyword research and audience analysis. Your job is to produce structured research briefs
that writers use to create high-ranking blog content.
When given a content topic, you MUST respond with a valid JSON object containing:
- primary_keyword: the single best target keyword phrase (string)
- secondary_keywords: list of 4-6 supporting keyword phrases (list of strings)
- target_audience: description of who will read this content (string)
- pain_points: list of 3-5 specific problems this audience faces (list of strings)
- key_talking_points: list of 5-7 concrete points the article must cover (list of strings)
- recommended_word_count: ideal article length as an integer (integer)
- content_angle: the unique angle or hook that makes this article stand out (string)
- suggested_h2_headings: list of 4-6 suggested section headings (list of strings)
Return ONLY the JSON object. No preamble, no explanation, no markdown fences."""
# Tool definition for structured research output
self.tools = [
{
"name": "generate_research_brief",
"description": "Generate a structured SEO research brief for a given content topic",
"input_schema": {
"type": "object",
"properties": {
"primary_keyword": {
"type": "string",
"description": "The main target keyword phrase for the article"
},
"secondary_keywords": {
"type": "array",
"items": {"type": "string"},
"description": "Supporting keyword phrases to include naturally"
},
"target_audience": {
"type": "string",
"description": "Description of the primary reader persona"
},
"pain_points": {
"type": "array",
"items": {"type": "string"},
"description": "Specific problems the audience is trying to solve"
},
"key_talking_points": {
"type": "array",
"items": {"type": "string"},
"description": "Core points the article must cover"
},
"recommended_word_count": {
"type": "integer",
"description": "Recommended article length in words"
},
"content_angle": {
"type": "string",
"description": "The unique hook or angle that differentiates this article"
},
"suggested_h2_headings": {
"type": "array",
"items": {"type": "string"},
"description": "Suggested H2 section headings for the article structure"
}
},
"required": [
"primary_keyword",
"secondary_keywords",
"target_audience",
"pain_points",
"key_talking_points",
"recommended_word_count",
"content_angle",
"suggested_h2_headings"
]
}
}
]
def run(self, topic: str) -> dict:
"""
Takes a topic string and returns a structured research brief as a dict.
Uses Claude's tool use feature to enforce structured JSON output.
"""
print(f"\n[Researcher Agent] Analyzing topic: '{topic}'")
messages = [
{
"role": "user",
"content": f"Create a comprehensive SEO research brief for this topic: {topic}"
}
]
response = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
system=self.system_prompt,
tools=self.tools,
# Force Claude to use the tool for guaranteed structured output
tool_choice={"type": "tool", "name": "generate_research_brief"},
messages=messages
)
# Extract the tool use block from the response
for block in response.content:
if block.type == "tool_use" and block.name == "generate_research_brief":
research_brief = block.input
print(f"[Researcher Agent] Brief generated. Primary keyword: '{research_brief['primary_keyword']}'")
return research_brief
# Fallback: should never hit this if tool_choice is set correctly
raise ValueError("Researcher Agent failed to return a structured brief.")The key move here is tool_choice={"type": "tool", "name": "generate_research_brief"}. That forces Claude to respond through the tool schema every single time — no free-text responses, no format drift. This is one of the most important prompt engineering best practices for agentic pipelines: when you need structured output, enforce it at the API level, not just in your prompt text.
Without
tool_choice forced, Claude might occasionally respond in plain text instead of using the tool — especially on ambiguous topics. Forcing tool use guarantees your downstream agents always receive a dict with the exact keys they expect. Never rely on prompt instructions alone to enforce output structure in a production pipeline.
6. Step 3: Build the SEO Optimizer Agent
The SEO Optimizer Agent receives the research brief from Agent 1 and writes the full blog post. It applies specific on-page SEO rules through its system prompt — things like keyword density, heading structure, and meta description format.
seo_optimizer_agent.pyfrom config import client, MODEL, MAX_TOKENS
class SEOOptimizerAgent:
"""
Agent 2: Writes a full SEO-optimized blog post from a research brief.
Applies on-page SEO rules including keyword placement, heading structure,
and internal linking guidance.
"""
def __init__(self):
self.system_prompt = """You are a professional SEO content writer who produces
high-ranking, human-sounding blog posts. You never write generic filler content.
Every paragraph adds specific value.
Follow these SEO optimization rules exactly:
1. Include the primary keyword in the first 100 words of the introduction
2. Use the primary keyword 3-5 times total throughout the article (natural density)
3. Include at least 3 secondary keywords, each used 1-2 times
4. Write an H1 title that includes the primary keyword
5. Use H2 headings for major sections (use the suggested headings from the brief)
6. Use H3 subheadings where it improves scannability
7. Write a meta description under 160 characters that includes the primary keyword
8. Include a clear call-to-action in the final section
9. Keep paragraphs to 3 sentences or fewer
10. Write in second person ("you") to speak directly to the reader
Structure your response exactly like this:
META_DESCRIPTION: [your meta description here]
---
[Full article starting with H1]"""
def run(self, research_brief: dict) -> str:
"""
Takes a research brief dict and returns a full SEO-optimized blog post as a string.
"""
print(f"\n[SEO Optimizer Agent] Writing article for keyword: '{research_brief['primary_keyword']}'")
# Format the research brief into a clear prompt for the writer agent
brief_formatted = f"""
Use this research brief to write the blog post:
PRIMARY KEYWORD: {research_brief['primary_keyword']}
SECONDARY KEYWORDS: {', '.join(research_brief['secondary_keywords'])}
TARGET AUDIENCE: {research_brief['target_audience']}
PAIN POINTS TO ADDRESS:
{chr(10).join(f'- {point}' for point in research_brief['pain_points'])}
KEY TALKING POINTS:
{chr(10).join(f'- {point}' for point in research_brief['key_talking_points'])}
TARGET WORD COUNT: {research_brief['recommended_word_count']} words
CONTENT ANGLE: {research_brief['content_angle']}
SUGGESTED H2 HEADINGS:
{chr(10).join(f'- {heading}' for heading in research_brief['suggested_h2_headings'])}
Write the complete blog post now. Follow all SEO optimization rules from your instructions.
"""
messages = [
{
"role": "user",
"content": brief_formatted
}
]
response = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
system=self.system_prompt,
messages=messages
)
article_content = response.content[0].text
word_count = len(article_content.split())
print(f"[SEO Optimizer Agent] Article written. Word count: ~{word_count}")
return article_contentNotice how the system prompt contains explicit, numbered rules. This is deliberate. Vague instructions like "write an SEO article" produce vague results. Numbered rules give Claude a checklist it can internally verify against before responding — that's a core prompt engineering best practice for content generation tasks.
7. Step 4: Create the Quality Reviewer Agent
The Quality Reviewer Agent is the final gate before output. It reads the finished article and scores it against a rubric, then returns a structured evaluation with a pass/fail verdict and specific revision notes.
quality_reviewer_agent.pyfrom config import client, MODEL, MAX_TOKENS
class QualityReviewerAgent:
"""
Agent 3: Evaluates a finished article against an SEO and quality rubric.
Returns a structured review with scores, pass/fail verdict, and revision notes.
"""
def __init__(self):
self.system_prompt = """You are a senior content editor and SEO auditor.
Your job is to evaluate blog posts against a strict quality rubric and provide
actionable, specific feedback.
Evaluate each article on these criteria (score each 1-10):
1. keyword_usage: Primary keyword appears naturally 3-5 times, not stuffed
2. heading_structure: Logical H1 > H2 > H3 hierarchy, headings are descriptive
3. readability: Short paragraphs, clear sentences, no jargon overload
4. content_value: Specific, useful information — not generic filler
5. cta_presence: Clear, compelling call-to-action in the final section
6. meta_description: Present, under 160 chars, includes primary keyword
Scoring:
- 8-10: Excellent, no changes needed
- 5-7: Acceptable, minor improvements suggested
- 1-4: Needs significant revision
PASS threshold: All scores must be 6 or higher AND average score must be 7 or higher.
Respond ONLY with this exact JSON structure:
{
"scores": {
"keyword_usage": ,
"heading_structure": ,
"readability": ,
"content_value": ,
"cta_presence": ,
"meta_description":
},
"average_score": ,
"verdict": "PASS" or "FAIL",
"revision_notes": [list of specific, actionable revision suggestions],
"strongest_section": "",
"weakest_section": ""
}"""
def run(self, article_content: str, research_brief: dict) -> dict:
"""
Evaluates the article against the research brief and returns a review dict.
"""
print(f"\n[Quality Reviewer Agent] Reviewing article...")
review_prompt = f"""Review this article against the following research brief context:
PRIMARY KEYWORD: {research_brief['primary_keyword']}
TARGET AUDIENCE: {research_brief['target_audience']}
ARTICLE TO REVIEW:
{article_content}
Apply the evaluation rubric from your instructions and return only the JSON review object."""
messages = [
{
"role": "user",
"content": review_prompt
}
]
response = client.messages.create(
model=MODEL,
max_tokens=MAX_TOKENS,
system=self.system_prompt,
messages=messages
)
raw_response = response.content[0].text.strip()
# Strip markdown code fences if Claude wraps the JSON in them
if raw_response.startswith("```"):
lines = raw_response.split("\n")
# Remove first and last lines (the ``` fences)
raw_response = "\n".join(lines[1:-1])
import json
review = json.loads(raw_response)
verdict = review.get("verdict", "UNKNOWN")
avg = review.get("average_score", 0)
print(f"[Quality Reviewer Agent] Verdict: {verdict} | Average Score: {avg}/10")
return review The reviewer gets both the article and the original research brief. That's important — it needs to know the target keyword and audience to judge whether the article actually serves that brief. Passing context forward through the pipeline like this is what separates a toy demo from a real agentic AI workflow automation system.
8. Step 5: Orchestrate the Multi-Agent Pipeline
The orchestrator ties everything together. It runs the three agents in sequence, handles the data handoff between them, and manages retry logic if the reviewer returns a FAIL verdict. This is where the agentic loop lives.
orchestrator.pyimport json
import time
from researcher_agent import ResearcherAgent
from seo_optimizer_agent import SEOOptimizerAgent
from quality_reviewer_agent import QualityReviewerAgent
class ContentPipelineOrchestrator:
"""
Orchestrates the 3-agent SEO content pipeline.
Runs: Researcher -> SEO Optimizer -> Quality Reviewer
Retries the optimizer once if the reviewer returns a FAIL verdict.
"""
def __init__(self, max_retries: int = 2):
self.researcher = ResearcherAgent()
self.optimizer = SEOOptimizerAgent()
self.reviewer = QualityReviewerAgent()
self.max_retries = max_retries
def run(self, topic: str) -> dict:
"""
Full pipeline run from topic string to reviewed article.
Returns a results dict with the research brief, article, and review.
"""
pipeline_start = time.time()
print(f"\n{'='*60}")
print(f"PIPELINE START: {topic}")
print(f"{'='*60}")
# --- Agent 1: Research ---
research_brief = self.researcher.run(topic)
# --- Agent 2: Write (with retry loop) ---
article_content = None
review = None
attempt = 0
while attempt < self.max_retries:
attempt += 1
print(f"\n[Orchestrator] Writing attempt {attempt} of {self.max_retries}")
# Pass the full research brief to the optimizer
article_content = self.optimizer.run(research_brief)
# --- Agent 3: Review ---
review = self.reviewer.run(article_content, research_brief)
if review.get("verdict") == "PASS":
print(f"\n[Orchestrator] Article PASSED review on attempt {attempt}.")
break
else:
print(f"\n[Orchestrator] Article FAILED review. Revision notes:")
for note in review.get("revision_notes", []):
print(f" - {note}")
if attempt < self.max_retries:
# Inject reviewer feedback into the research brief for the next attempt
revision_context = "\n".join(review.get("revision_notes", []))
research_brief["revision_notes"] = revision_context
print(f"[Orchestrator] Retrying with revision feedback injected...")
else:
print(f"[Orchestrator] Max retries reached. Returning best attempt.")
elapsed = round(time.time() - pipeline_start, 2)
print(f"\n{'='*60}")
print(f"PIPELINE COMPLETE in {elapsed}s")
print(f"{'='*60}\n")
return {
"topic": topic,
"research_brief": research_brief,
"article": article_content,
"review": review,
"elapsed_seconds": elapsed
}
def save_output(self, results: dict, filename: str = "output.json") -> None:
"""Saves the full pipeline results to a JSON file for inspection."""
with open(filename, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"[Orchestrator
Naples AI