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.
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
anthropicSDK installed (pip install anthropic)python-dotenvfor environment variable management (pip install python-dotenv)- A
.envfile with yourANTHROPIC_API_KEYset
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.
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.shmkdir 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.pyimport 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.")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_articleStep 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.
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.pyimport 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)