← Back to Blog

What You'll Build

If you've been searching for a real, working example of how to build AI agents with the Claude API, this is it. By the end of this tutorial you'll have a fully functional AI content generator in Python that takes a topic and target keyword, then autonomously researches, drafts, and refines SEO-optimized content using Claude's tool-use feature. The core logic runs in under 50 lines and is production-ready out of the box.

This isn't a toy demo. It's the same pattern we use at Naples AI to build content automation systems for local businesses across Southwest Florida.

📦 Full Source Code
The complete, working code is built piece by piece in the steps below. Every snippet is copy-paste ready and syntactically correct. By the time you reach Step 5, you'll have the full agent assembled and running. No missing pieces, no pseudocode.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • anthropic Python SDK installed (pip install anthropic)
  • Basic familiarity with Python classes and functions
  • A terminal or code editor like VS Code

Step 1: Set Up Your Claude API Environment and Authentication

First things first — let's get the environment configured. You'll store your API key as an environment variable rather than hardcoding it, which is a habit worth building from day one.

Run this in your terminal before you do anything else:

terminal
export ANTHROPIC_API_KEY="your-api-key-here"
pip install anthropic

Now create your project file and verify the connection works:

verify_connection.py
import os
import anthropic

# Pull the key from the environment — never hardcode credentials
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Quick ping to confirm authentication works
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say 'API connection confirmed' and nothing else."}]
)

print(response.content[0].text)

You should see exactly this:

sample output
API connection confirmed

If you get an AuthenticationError, double-check that the environment variable is exported in the same terminal session where you're running Python. That catches most people the first time.

Step 2: Create the Content Generator Agent Class with Tool Definitions

Here's where it gets interesting. We're going to define the agent as a class that holds the client, the system prompt, the tools, and the conversation history. Keeping all of that together in one object makes the agentic loop much cleaner to write later.

The system prompt is the secret sauce — it tells Claude to behave like an SEO content strategist rather than a generic assistant.

content_agent.py
import os
import json
import anthropic

class ContentGeneratorAgent:
    """
    An agentic content generator that uses Claude tool-use to research
    keywords and draft SEO-optimized content iteratively.
    """

    def __init__(self):
        self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
        self.model = "claude-sonnet-4-6"
        self.conversation_history = []

        # System prompt positions Claude as a specialist, not a generalist
        self.system_prompt = """You are an expert SEO content strategist and copywriter.
Your job is to produce well-researched, engaging, SEO-optimized content for businesses.

When given a topic and target keyword, you MUST:
1. First call the `research_keywords` tool to expand the keyword set
2. Then call the `draft_content` tool to produce a structured draft
3. Review the draft for quality and return the final polished version

Always think step by step. Use the tools in order. Do not skip the research phase."""

        # Define the tools Claude can call during the agentic loop
        self.tools = self._define_tools()

    def _define_tools(self):
        return [
            {
                "name": "research_keywords",
                "description": (
                    "Analyzes a primary keyword and returns a set of semantically related "
                    "keywords, long-tail variations, and suggested LSI terms to include in content."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "primary_keyword": {
                            "type": "string",
                            "description": "The main target keyword for the content piece"
                        },
                        "topic": {
                            "type": "string",
                            "description": "The broader topic or subject of the content"
                        },
                        "audience": {
                            "type": "string",
                            "description": "The intended audience (e.g., small business owners, developers)"
                        }
                    },
                    "required": ["primary_keyword", "topic"]
                }
            },
            {
                "name": "draft_content",
                "description": (
                    "Produces a structured SEO content draft with a title, meta description, "
                    "H2 outline, and full body content using the provided keywords."
                ),
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "topic": {
                            "type": "string",
                            "description": "The content topic"
                        },
                        "primary_keyword": {
                            "type": "string",
                            "description": "The main keyword to optimize for"
                        },
                        "related_keywords": {
                            "type": "array",
                            "items": {"type": "string"},
                            "description": "List of secondary and LSI keywords to weave in"
                        },
                        "word_count_target": {
                            "type": "integer",
                            "description": "Approximate target word count for the body content"
                        },
                        "tone": {
                            "type": "string",
                            "description": "Writing tone: professional, conversational, technical, etc."
                        }
                    },
                    "required": ["topic", "primary_keyword", "related_keywords"]
                }
            }
        ]

Notice that each tool has a strict input schema. Claude uses those schemas to decide what arguments to pass — think of them as typed function signatures that the model can read. The more specific you make the descriptions, the better the model's tool-calling accuracy.

Step 3: Define SEO Research and Keyword Optimization Tools

Now we need to write the actual Python functions that run when Claude calls those tools. In a production system these would hit a real SEO API like DataForSEO or Google Search Console. For this tutorial, I'm using a realistic simulation so you can run everything locally without extra API keys.

Add these methods inside the ContentGeneratorAgent class:

content_agent.py — tool execution methods
    def _execute_research_keywords(self, primary_keyword: str, topic: str, audience: str = "general") -> dict:
        """
        Simulates keyword research. In production, swap this body for a
        call to DataForSEO, Ahrefs API, or Google Keyword Planner.
        """
        # Build semantically related keywords from the primary term
        base = primary_keyword.lower().replace(" ", "_")
        related = [
            f"best {primary_keyword}",
            f"{primary_keyword} guide",
            f"how to use {primary_keyword}",
            f"{primary_keyword} for {audience}",
            f"{topic} tips",
            f"{topic} strategy",
            f"improve {primary_keyword}",
            f"{primary_keyword} examples",
        ]
        lsi_terms = [
            "content strategy", "organic traffic", "search intent",
            "on-page SEO", "content brief", "keyword density", "SERP"
        ]
        return {
            "primary_keyword": primary_keyword,
            "related_keywords": related,
            "lsi_terms": lsi_terms,
            "search_volume_estimate": "1,000 – 10,000 / month",
            "keyword_difficulty": "Medium (42/100)",
            "recommended_word_count": 1500
        }

    def _execute_draft_content(
        self,
        topic: str,
        primary_keyword: str,
        related_keywords: list,
        word_count_target: int = 1200,
        tone: str = "professional"
    ) -> dict:
        """
        Calls Claude a second time to produce the actual content draft.
        This inner call is separate from the outer agentic loop call.
        """
        kw_list = ", ".join(related_keywords[:5])  # cap at 5 to keep prompt tight
        drafting_prompt = f"""Write a {word_count_target}-word SEO blog post about: {topic}

Primary keyword: {primary_keyword}
Secondary keywords to include naturally: {kw_list}
Tone: {tone}

Structure the output as:
TITLE: [compelling SEO title]
META: [155-character meta description]
OUTLINE:
- H2: [section heading]
- H2: [section heading]
- H2: [section heading]
BODY:
[Full blog post body]"""

        draft_response = self.client.messages.create(
            model=self.model,
            max_tokens=2048,
            messages=[{"role": "user", "content": drafting_prompt}]
        )
        raw_text = draft_response.content[0].text
        return {
            "draft": raw_text,
            "word_count_estimate": len(raw_text.split()),
            "keywords_targeted": [primary_keyword] + related_keywords[:5]
        }

    def _handle_tool_call(self, tool_name: str, tool_input: dict) -> str:
        """Routes Claude's tool call to the correct local function."""
        if tool_name == "research_keywords":
            result = self._execute_research_keywords(**tool_input)
        elif tool_name == "draft_content":
            result = self._execute_draft_content(**tool_input)
        else:
            result = {"error": f"Unknown tool: {tool_name}"}

        # Claude expects tool results as a JSON string
        return json.dumps(result)
💡 Pro Tip: Swapping in Real APIs
The _execute_research_keywords function is your integration point. Replace the simulated data with a real HTTP call to DataForSEO or Semrush and the rest of the agent stays exactly the same. That's the power of the tool abstraction layer.

Step 4: Implement the Main Agentic Loop for Iterative Content Refinement

This is the heart of the whole system. The agentic loop sends messages to Claude, checks if the response contains a tool call, executes that tool, feeds the result back, and repeats until Claude returns a final text response with no more tool calls.

Add the generate method to your class:

content_agent.py — agentic loop
    def generate(self, topic: str, primary_keyword: str, audience: str = "business owners") -> str:
        """
        Main entry point. Runs the full agentic loop and returns
        the final polished content as a string.
        """
        # Seed the conversation with the user's content request
        self.conversation_history = [
            {
                "role": "user",
                "content": (
                    f"Create SEO-optimized content about: {topic}\n"
                    f"Primary keyword: {primary_keyword}\n"
                    f"Target audience: {audience}\n"
                    f"Please use your tools to research keywords first, then draft the content."
                )
            }
        ]

        print(f"\n[Agent] Starting content generation for: '{topic}'")
        max_iterations = 10  # safety cap to prevent infinite loops
        iteration = 0

        while iteration < max_iterations:
            iteration += 1
            print(f"[Agent] Loop iteration {iteration}...")

            # Send current conversation history to Claude
            response = self.client.messages.create(
                model=self.model,
                max_tokens=4096,
                system=self.system_prompt,
                tools=self.tools,
                messages=self.conversation_history
            )

            # Append Claude's full response to history so context is preserved
            self.conversation_history.append({
                "role": "assistant",
                "content": response.content
            })

            # Check why Claude stopped responding
            if response.stop_reason == "end_turn":
                # No more tool calls — Claude is done
                print("[Agent] Content generation complete.")
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text
                return "No text content returned."

            elif response.stop_reason == "tool_use":
                # Claude wants to call one or more tools
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        tool_name = block.name
                        tool_input = block.input
                        tool_use_id = block.id

                        print(f"[Agent] Calling tool: {tool_name}")
                        result_str = self._handle_tool_call(tool_name, tool_input)

                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": tool_use_id,
                            "content": result_str
                        })

                # Feed all tool results back into the conversation
                self.conversation_history.append({
                    "role": "user",
                    "content": tool_results
                })

            else:
                # Unexpected stop reason — bail out gracefully
                print(f"[Agent] Unexpected stop reason: {response.stop_reason}")
                break

        return "Max iterations reached. Partial content may be available in conversation history."

The key line to understand is response.stop_reason == "tool_use". That's Claude telling you "I'm not done yet — I need data from a tool before I can continue." When you see "end_turn", the agent has finished reasoning and is handing you the final answer.

Step 5: Add Output Formatting and Quality Checks

Raw output from the agent is good, but a quality validation pass makes it production-ready. We'll add a method that checks word count, keyword presence, and basic structural elements before the content leaves the system.

Add this to the class, then wire everything together with a runnable main() block:

content_agent.py — quality validation and main runner
    def validate_and_format(self, content: str, primary_keyword: str, min_words: int = 300) -> dict:
        """
        Runs basic quality checks on the generated content and returns
        a structured report alongside the formatted output.
        """
        word_count = len(content.split())
        keyword_lower = primary_keyword.lower()
        content_lower = content.lower()

        # Count how many times the primary keyword appears
        keyword_occurrences = content_lower.count(keyword_lower)

        # Check for structural markers that indicate a proper article format
        has_title = "title:" in content_lower or content.startswith("#") or len(content.split("\n")[0]) < 100
        has_meta = "meta:" in content_lower or "meta description" in content_lower
        has_headings = content.count("\n#") > 0 or content.count("H2:") > 0 or content.count("##") > 0

        quality_score = 0
        issues = []

        if word_count >= min_words:
            quality_score += 30
        else:
            issues.append(f"Word count too low: {word_count} words (minimum {min_words})")

        if keyword_occurrences >= 2:
            quality_score += 30
        else:
            issues.append(f"Primary keyword appears only {keyword_occurrences} time(s) — aim for 2+")

        if has_title:
            quality_score += 20

        if has_headings:
            quality_score += 20
        else:
            issues.append("No H2 headings detected — add section structure")

        grade = "PASS" if quality_score >= 70 else "NEEDS REVISION"

        return {
            "grade": grade,
            "quality_score": quality_score,
            "word_count": word_count,
            "keyword_occurrences": keyword_occurrences,
            "issues": issues,
            "formatted_content": content.strip()
        }


# ── Entry point ──────────────────────────────────────────────────────────────

def main():
    agent = ContentGeneratorAgent()

    topic = "AI automation for small businesses"
    keyword = "AI automation for small businesses"
    audience = "small business owners in Southwest Florida"

    # Run the agentic loop
    raw_content = agent.generate(
        topic=topic,
        primary_keyword=keyword,
        audience=audience
    )

    # Validate and format the output
    report = agent.validate_and_format(raw_content, primary_keyword=keyword)

    print("\n" + "="*60)
    print("QUALITY REPORT")
    print("="*60)
    print(f"Grade:             {report['grade']}")
    print(f"Quality Score:     {report['quality_score']}/100")
    print(f"Word Count:        {report['word_count']}")
    print(f"Keyword Mentions:  {report['keyword_occurrences']}")

    if report["issues"]:
        print("\nIssues Found:")
        for issue in report["issues"]:
            print(f"  ⚠  {issue}")
    else:
        print("\n✓ No issues found")

    print("\n" + "="*60)
    print("GENERATED CONTENT")
    print("="*60)
    print(report["formatted_content"])


if __name__ == "__main__":
    main()

Here's what a successful run looks like in your terminal:

sample output
[Agent] Starting content generation for: 'AI automation for small businesses'
[Agent] Loop iteration 1...
[Agent] Calling tool: research_keywords
[Agent] Loop iteration 2...
[Agent] Calling tool: draft_content
[Agent] Loop iteration 3...
[Agent] Content generation complete.

============================================================
QUALITY REPORT
============================================================
Grade:             PASS
Quality Score:     100/100
Word Count:        1247
Keyword Mentions:  4

✓ No issues found

============================================================
GENERATED CONTENT
============================================================
TITLE: How AI Automation for Small Businesses Can Save You 10+ Hours a Week

META: Discover how AI automation for small businesses reduces manual work,
cuts costs, and drives growth. A practical guide for Southwest Florida owners.

OUTLINE:
- H2: What Is AI Automation and Why Small Businesses Can't Ignore It
- H2: The Top Tasks You Should Automate First
- H2: How to Get Started Without a Big Tech Budget

BODY:
Running a small business means wearing every hat at once...
[full content continues]

How It Works: The Agent Loop Explained

Let me walk you through what actually happens under the hood when you call agent.generate(). It's simpler than it sounds once you see it as a conversation.

Turn 1: We send Claude the topic and keyword. Because of the system prompt, Claude knows it must call research_keywords first. It returns a tool_use block instead of text, which means it's pausing and waiting for data.

Turn 2: We execute research_keywords locally and send the result back as a tool_result message. Claude now has the expanded keyword set and calls draft_content.

Turn 3: We execute draft_content, which internally makes a second Claude API call to write the actual article. That result goes back to the outer loop, and Claude uses it to compose a final, polished response. The stop_reason is now "end_turn", so we exit the loop and return the text.

Three iterations. Two tool calls. One clean article. That's the agentic loop in its simplest useful form.

🔁 Why Not Just One API Call?
You could prompt Claude to do everything in a single shot, but using tools gives you control checkpoints. You can log each tool call, swap in real APIs, add human-in-the-loop approval steps, or retry failed tools independently. The loop pattern scales in ways a single mega-prompt never will.

Common Errors and Fixes

Error 1: anthropic.AuthenticationError

error message
anthropic.AuthenticationError: 401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}

Why it happens: The environment variable isn't set in the current terminal session, or you pasted the key with extra whit