An SEO Content Pipeline Powered by Claude: AI SEO Content Generation from Keyword to Publish-Ready Bundle

Series
AI in Production: 30 Real-World Use Cases with Claude

Part 18 of 30 · View the full series

TL;DR

  • A five-stage Claude pipeline (keyword analysis, outline, full draft, meta title/description, JSON-LD) produces a publish-ready content bundle in under 90 seconds for roughly $0.04 per article.
  • Each stage is a separate API call with its own focused prompt, which keeps individual outputs short and predictable rather than asking one call to do everything at once.
  • Structured output via forced tool use ensures every stage returns machine-readable JSON your CMS can ingest without fragile string parsing.
  • Model selection matters: Haiku for keyword clustering and outline, Sonnet for the full draft, Sonnet again for meta copy, all chained by a thin Python orchestrator.
  • The full POC includes retry logic, prompt caching on the shared system prompt, and a final bundle that a WordPress REST API call can publish directly.
  • AI SEO content generation is not a replacement for subject-matter expertise; it is a first-draft accelerator that compresses the blank-page problem from hours to minutes.

Why Teams Are Building AI SEO Content Generation Pipelines

Most content teams have the same bottleneck. The keyword research is done. The editorial calendar exists. The brief is written. And then a writer stares at a blank document for two hours before producing 600 words that need heavy editing. That blank-page tax, multiplied across dozens of topics per month, is where growth compounds get eaten.

AI SEO content generation addresses exactly this problem. Not by producing final copy ready to ship without review, but by collapsing the blank-page phase to near zero. A well-structured pipeline takes a target keyword, runs it through a series of focused Claude calls, and returns a structured bundle: an outline, a complete draft with proper heading hierarchy, a meta title and description optimized for click-through, and schema markup ready for the <head>. A human editor then shapes and fact-checks that draft rather than writing from scratch.

The economics are compelling. At current Claude pricing, a 1,500-word draft with full metadata costs roughly $0.03 to $0.06 in tokens, depending on model mix. Even at ten times that cost, you are paying less than $0.50 per article at scale. The real saving is time: an editor who can review and polish a draft in 30 minutes is far more productive than a writer who needs 3 hours to produce the same first draft.

This article builds the complete pipeline end to end, including structured output, prompt caching, retry handling, and a final JSON bundle you can POST to the WordPress REST API. Every code block is real and runnable against the Claude API.

Pipeline Architecture: Five Stages, Five Focused Calls

The central design decision is to break the work into small, focused stages rather than one massive prompt. A single prompt asking Claude to “write a full SEO article with outline, meta, and schema” produces inconsistent results because the task is too broad. Breaking it into stages gives each call a clear job, a predictable output format, and a checkable intermediate result.

Keyword Analysis Haiku

Outline Generator Haiku

Draft Writer Sonnet

Meta Copy Writer Haiku

JSON-LD Generator Haiku

Final bundle: outline + HTML draft + meta + JSON-LD schema

Figure 1: The five-stage SEO pipeline. Each stage is a separate Claude call, allowing targeted model selection and per-stage validation.

Stage 1: Keyword Analysis

The first call takes the seed keyword and returns a structured analysis: the primary topic, 5 to 8 semantically related terms, the likely search intent (informational, transactional, navigational), and a suggested heading structure for SEO. This is fast, cheap work that Haiku handles well. The output feeds every downstream stage.

Stage 2: Outline Generation

The second call receives the keyword analysis and produces a hierarchical outline in JSON: H1, H2 sections, and H3 subsections with brief notes on what each section should cover. Structuring the outline as data (rather than markdown) means the draft stage can iterate over sections programmatically, which keeps the draft call prompt tight.

Stage 3: Full Draft

This is the most expensive call. Sonnet receives the keyword analysis, the outline, and a content brief (target word count, tone, audience), then writes the full article as HTML. Asking for HTML output rather than markdown avoids a conversion step before WordPress import. The draft prompt includes the related terms list from Stage 1 so Claude weaves them in naturally.

Stage 4: Meta Title and Description

A focused Haiku call receives the draft and the primary keyword, then returns a meta title (under 60 characters) and meta description (under 155 characters) optimized for click-through. Small, fast, reliable. Do not ask the draft-writing call to also write meta copy; it tends to produce titles that are too long or too generic.

Stage 5: JSON-LD Schema

The final Haiku call produces an Article schema object as valid JSON, using the draft’s first paragraph as the description and pulling the meta title as the headline. The output is ready to embed in a <script type="application/ld+json"> tag.

Model Selection for an AI SEO Content Generation Pipeline

Picking the right model per stage is the single most effective cost lever in this pipeline. The rule is simple: use the cheapest model that reliably handles the task.

Stage Model Reason Typical output tokens
Keyword analysis claude-haiku-4-5 Short structured JSON, no creativity needed 200 to 400
Outline generation claude-haiku-4-5 Hierarchical JSON, modest reasoning 300 to 600
Full draft claude-sonnet-4-6 Long-form coherent prose, natural keyword weaving 1,500 to 3,500
Meta copy claude-haiku-4-5 Two short strings, character count constraints 60 to 120
JSON-LD claude-haiku-4-5 Templated JSON from existing data 200 to 400

Running Stage 3 on Opus instead of Sonnet roughly triples the cost of the whole pipeline with marginal quality gains for most content categories. Reserve Opus for cases where factual depth and nuanced argumentation are genuinely needed, such as technical white papers or regulatory analysis. For standard informational SEO articles, Sonnet is the right default.

You can also look at Part 27’s guide on model routing and batching for a systematic approach to routing decisions when you are running the pipeline at scale.

Structured Output: Forcing JSON at Every Stage

Asking Claude to “return JSON” in a plain prompt works most of the time but fails just often enough to break a production pipeline. The right approach is to use Claude’s tool use feature with tool_choice={"type": "tool", "name": "your_tool"} to force a structured response every time.

This connects to the broader pattern described in Part 3 on structured output. The short version: define a tool whose input_schema matches the shape you want, force the model to call it, and read block.input. You get a Python dict, not a string that needs parsing.

Key idea: Structured output via forced tool use is not just a convenience. It is the boundary between a demo and a production system. When your downstream CMS code expects a dict with specific keys, you need a guarantee, not a probability.

Schema Design for Each Stage

Each stage tool schema is minimal. For keyword analysis, the schema has six top-level string and array fields. For the outline, it has an array of section objects, each with a title and an array of subsection objects. For the draft, it is a single html_content string field. For meta copy, it is meta_title and meta_description. For JSON-LD, it is a schema_json string field containing the stringified schema object.

Keeping schemas minimal reduces the chance of hallucinated extra fields and makes the code that consumes them simpler. If you need additional fields later, add them one at a time and test each addition.

Prompt Caching to Cut Costs at Scale

When you run this pipeline for 50 articles per day, a shared system prompt (SEO guidelines, brand voice rules, content standards) appears in every call. That is a lot of repeated input tokens. Prompt caching makes those tokens essentially free after the first call in a cache window.

The pattern from Part 4 on prompt caching applies directly here: make the system prompt a content list, mark the shared portion with "cache_control": {"type": "ephemeral"}, and track msg.usage.cache_read_input_tokens to confirm hits.

pip install anthropic python-dotenv

The cache TTL is five minutes. If your pipeline runs stages sequentially within that window (which it will, since the full five-stage pipeline takes 30 to 90 seconds), the shared system prompt is cached across all five calls for a given article. At 50 articles per day, that cache hit rate translates to roughly 30 to 40 percent total token cost reduction on the system prompt portion.

The Complete POC: Full Python Source

Below is the complete, runnable pipeline. Three files: requirements.txt, .env.example, and pipeline.py. No elisions. Every stage is implemented, including structured output, prompt caching, retry logic, and a final bundle assembly.

requirements.txt

anthropic>=0.30.0
python-dotenv>=1.0.0

.env.example

# Copy to .env and fill in your key
ANTHROPIC_API_KEY=your_api_key_here

pipeline.py (complete source)

"""
SEO Content Pipeline: keyword -> outline -> draft -> meta -> JSON-LD
Each stage is a separate Claude API call returning structured JSON.
Run: python pipeline.py
"""

import json
import os
import time
from typing import Any

import anthropic
from dotenv import load_dotenv

load_dotenv()

client = anthropic.Anthropic()

# ---------------------------------------------------------------------------
# Shared system prompt (cached across all five stages per pipeline run)
# ---------------------------------------------------------------------------
SHARED_SYSTEM = (
    "You are an expert SEO content strategist and writer. "
    "You produce accurate, well-structured, reader-first content that also satisfies "
    "modern search engine requirements. "
    "You never keyword-stuff. You write for humans first. "
    "You always return output in the exact JSON structure requested via the tool schema. "
    "Factual claims should be supportable; do not invent statistics."
)


# ---------------------------------------------------------------------------
# Retry helper
# ---------------------------------------------------------------------------
def call_with_retry(
    fn,
    max_attempts: int = 3,
    backoff_base: float = 1.5,
) -> Any:
    """Call fn(), retrying on APIError with exponential backoff."""
    for attempt in range(1, max_attempts + 1):
        try:
            return fn()
        except anthropic.APIError as exc:
            if attempt == max_attempts:
                raise
            wait = backoff_base ** attempt
            print(f"  API error ({exc}), retrying in {wait:.1f}s...")
            time.sleep(wait)


# ---------------------------------------------------------------------------
# Stage 1: Keyword Analysis
# ---------------------------------------------------------------------------
KEYWORD_ANALYSIS_TOOL = {
    "name": "keyword_analysis",
    "description": "Return a structured SEO keyword analysis for the given seed keyword.",
    "input_schema": {
        "type": "object",
        "properties": {
            "primary_keyword": {
                "type": "string",
                "description": "The exact seed keyword as provided.",
            },
            "topic_summary": {
                "type": "string",
                "description": "One sentence describing the topic and target audience.",
            },
            "search_intent": {
                "type": "string",
                "enum": ["informational", "transactional", "navigational", "commercial"],
                "description": "The dominant search intent for this keyword.",
            },
            "related_terms": {
                "type": "array",
                "items": {"type": "string"},
                "description": "5 to 8 semantically related phrases to weave into the content.",
            },
            "suggested_h1": {
                "type": "string",
                "description": "A compelling H1 title (under 70 characters) containing the primary keyword.",
            },
            "content_angle": {
                "type": "string",
                "description": "The unique angle or perspective this article should take.",
            },
        },
        "required": [
            "primary_keyword",
            "topic_summary",
            "search_intent",
            "related_terms",
            "suggested_h1",
            "content_angle",
        ],
    },
}


def stage1_keyword_analysis(seed_keyword: str) -> dict:
    print(f"[Stage 1] Analyzing keyword: '{seed_keyword}'")

    def _call():
        return client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=600,
            system=[
                {
                    "type": "text",
                    "text": SHARED_SYSTEM,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            tools=[KEYWORD_ANALYSIS_TOOL],
            tool_choice={"type": "tool", "name": "keyword_analysis"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Analyze the following seed keyword for an SEO article: "
                        f"'{seed_keyword}'. "
                        "Return your analysis using the keyword_analysis tool."
                    ),
                }
            ],
        )

    msg = call_with_retry(_call)
    print(f"  Tokens: {msg.usage.input_tokens} in / {msg.usage.output_tokens} out"
          f" | cache_read={msg.usage.cache_read_input_tokens}")

    for block in msg.content:
        if block.type == "tool_use" and block.name == "keyword_analysis":
            return block.input
    raise ValueError("Stage 1: no keyword_analysis tool block in response")


# ---------------------------------------------------------------------------
# Stage 2: Outline Generation
# ---------------------------------------------------------------------------
OUTLINE_TOOL = {
    "name": "article_outline",
    "description": "Return a hierarchical SEO article outline as structured data.",
    "input_schema": {
        "type": "object",
        "properties": {
            "h1": {
                "type": "string",
                "description": "The article H1 title.",
            },
            "intro_notes": {
                "type": "string",
                "description": "Brief notes on what the intro paragraph should cover.",
            },
            "sections": {
                "type": "array",
                "description": "H2 sections with optional H3 subsections.",
                "items": {
                    "type": "object",
                    "properties": {
                        "h2": {"type": "string"},
                        "notes": {"type": "string"},
                        "subsections": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "h3": {"type": "string"},
                                    "notes": {"type": "string"},
                                },
                                "required": ["h3"],
                            },
                        },
                    },
                    "required": ["h2", "notes"],
                },
            },
            "conclusion_notes": {
                "type": "string",
                "description": "Brief notes on the conclusion or call-to-action.",
            },
        },
        "required": ["h1", "intro_notes", "sections", "conclusion_notes"],
    },
}


def stage2_outline(analysis: dict, target_word_count: int = 1500) -> dict:
    print("[Stage 2] Generating outline...")

    def _call():
        return client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=900,
            system=[
                {
                    "type": "text",
                    "text": SHARED_SYSTEM,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            tools=[OUTLINE_TOOL],
            tool_choice={"type": "tool", "name": "article_outline"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Create a detailed SEO article outline based on this keyword analysis:\n\n"
                        f"{json.dumps(analysis, indent=2)}\n\n"
                        f"Target word count: {target_word_count} words.\n"
                        "Include 5 to 7 H2 sections. Add 2 to 3 H3 subsections under technical "
                        "or complex H2s. Each section note should specify what the section "
                        "must cover. Return using the article_outline tool."
                    ),
                }
            ],
        )

    msg = call_with_retry(_call)
    print(f"  Tokens: {msg.usage.input_tokens} in / {msg.usage.output_tokens} out"
          f" | cache_read={msg.usage.cache_read_input_tokens}")

    for block in msg.content:
        if block.type == "tool_use" and block.name == "article_outline":
            return block.input
    raise ValueError("Stage 2: no article_outline tool block in response")


# ---------------------------------------------------------------------------
# Stage 3: Full Draft
# ---------------------------------------------------------------------------
DRAFT_TOOL = {
    "name": "article_draft",
    "description": "Return the complete article draft as an HTML string.",
    "input_schema": {
        "type": "object",
        "properties": {
            "html_content": {
                "type": "string",
                "description": (
                    "The full article as valid HTML. Use semantic tags: "
                    "<h1>, <h2>, <h3>, <p>, <ul>, <ol>, <strong>. "
                    "Do not include <html>, <head>, or <body> wrappers. "
                    "Do not include meta tags or JSON-LD in the HTML."
                ),
            },
            "word_count_estimate": {
                "type": "integer",
                "description": "Estimated word count of the draft (excluding HTML tags).",
            },
        },
        "required": ["html_content", "word_count_estimate"],
    },
}


def stage3_draft(analysis: dict, outline: dict, target_word_count: int = 1500) -> dict:
    print("[Stage 3] Writing full draft (this may take 20-40 seconds)...")

    related = ", ".join(analysis.get("related_terms", []))
    outline_text = json.dumps(outline, indent=2)

    def _call():
        return client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=[
                {
                    "type": "text",
                    "text": SHARED_SYSTEM,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            tools=[DRAFT_TOOL],
            tool_choice={"type": "tool", "name": "article_draft"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Write a complete SEO article following this outline exactly:\n\n"
                        f"{outline_text}\n\n"
                        f"Primary keyword: {analysis['primary_keyword']}\n"
                        f"Related terms to weave in naturally: {related}\n"
                        f"Search intent: {analysis['search_intent']}\n"
                        f"Content angle: {analysis['content_angle']}\n"
                        f"Target word count: {target_word_count} words.\n\n"
                        "Requirements:\n"
                        "- Write in HTML (semantic tags only, no wrapper elements)\n"
                        "- Use the primary keyword in the first 100 words and at least two H2s\n"
                        "- Write for a technical audience (developers, founders)\n"
                        "- Use concrete examples and specific numbers where possible\n"
                        "- Vary sentence length; avoid filler phrases\n"
                        "- Do NOT use em-dashes or en-dashes\n"
                        "Return the full draft using the article_draft tool."
                    ),
                }
            ],
        )

    msg = call_with_retry(_call)
    print(f"  Tokens: {msg.usage.input_tokens} in / {msg.usage.output_tokens} out"
          f" | cache_read={msg.usage.cache_read_input_tokens}")

    for block in msg.content:
        if block.type == "tool_use" and block.name == "article_draft":
            return block.input
    raise ValueError("Stage 3: no article_draft tool block in response")


# ---------------------------------------------------------------------------
# Stage 4: Meta Title and Description
# ---------------------------------------------------------------------------
META_TOOL = {
    "name": "meta_copy",
    "description": "Return an SEO-optimized meta title and meta description.",
    "input_schema": {
        "type": "object",
        "properties": {
            "meta_title": {
                "type": "string",
                "description": (
                    "Meta title under 60 characters. Contains the primary keyword. "
                    "Compelling enough for users to click."
                ),
            },
            "meta_description": {
                "type": "string",
                "description": (
                    "Meta description between 120 and 155 characters. "
                    "Includes the primary keyword. Written as a value proposition, not a summary."
                ),
            },
            "og_title": {
                "type": "string",
                "description": "Open Graph title (can be slightly longer than meta title, under 95 chars).",
            },
        },
        "required": ["meta_title", "meta_description", "og_title"],
    },
}


def stage4_meta(analysis: dict, draft: dict) -> dict:
    print("[Stage 4] Writing meta copy...")

    # Pass only the first 500 chars of the draft HTML to keep this call cheap
    draft_preview = draft["html_content"][:500]

    def _call():
        return client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=300,
            system=[
                {
                    "type": "text",
                    "text": SHARED_SYSTEM,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            tools=[META_TOOL],
            tool_choice={"type": "tool", "name": "meta_copy"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Write SEO meta copy for an article about: "
                        f"'{analysis['primary_keyword']}'\n\n"
                        f"H1: {analysis['suggested_h1']}\n"
                        f"Search intent: {analysis['search_intent']}\n"
                        f"Draft preview: {draft_preview}\n\n"
                        "Return using the meta_copy tool. "
                        "The meta_title must be under 60 characters. "
                        "The meta_description must be 120 to 155 characters."
                    ),
                }
            ],
        )

    msg = call_with_retry(_call)
    print(f"  Tokens: {msg.usage.input_tokens} in / {msg.usage.output_tokens} out"
          f" | cache_read={msg.usage.cache_read_input_tokens}")

    for block in msg.content:
        if block.type == "tool_use" and block.name == "meta_copy":
            return block.input
    raise ValueError("Stage 4: no meta_copy tool block in response")


# ---------------------------------------------------------------------------
# Stage 5: JSON-LD Schema
# ---------------------------------------------------------------------------
JSONLD_TOOL = {
    "name": "jsonld_schema",
    "description": "Return a valid Article JSON-LD schema object as a JSON string.",
    "input_schema": {
        "type": "object",
        "properties": {
            "schema_json": {
                "type": "string",
                "description": (
                    "A valid JSON string representing an Article schema.org object. "
                    "Must include @context, @type, headline, description, author, "
                    "publisher, and datePublished fields."
                ),
            },
        },
        "required": ["schema_json"],
    },
}


def stage5_jsonld(meta: dict, analysis: dict, author: str = "Site Author") -> dict:
    print("[Stage 5] Generating JSON-LD schema...")

    def _call():
        return client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=400,
            system=[
                {
                    "type": "text",
                    "text": SHARED_SYSTEM,
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            tools=[JSONLD_TOOL],
            tool_choice={"type": "tool", "name": "jsonld_schema"},
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Generate a schema.org Article JSON-LD object for:\n"
                        f"Headline: {meta['meta_title']}\n"
                        f"Description: {meta['meta_description']}\n"
                        f"Author: {author}\n"
                        f"Publisher: My Blog\n"
                        f"Topic: {analysis['primary_keyword']}\n\n"
                        "Use '2024-01-01' as the datePublished placeholder. "
                        "Include articleSection based on the topic. "
                        "Return using the jsonld_schema tool. "
                        "The schema_json value must be valid JSON."
                    ),
                }
            ],
        )

    msg = call_with_retry(_call)
    print(f"  Tokens: {msg.usage.input_tokens} in / {msg.usage.output_tokens} out"
          f" | cache_read={msg.usage.cache_read_input_tokens}")

    for block in msg.content:
        if block.type == "tool_use" and block.name == "jsonld_schema":
            return block.input
    raise ValueError("Stage 5: no jsonld_schema tool block in response")


# ---------------------------------------------------------------------------
# Orchestrator: assemble the full pipeline
# ---------------------------------------------------------------------------
def run_pipeline(
    seed_keyword: str,
    target_word_count: int = 1500,
    author: str = "Site Author",
) -> dict:
    """
    Run all five stages and return a publish-ready content bundle.

    Returns a dict with keys:
        analysis, outline, draft, meta, jsonld, bundle_summary
    """
    start = time.time()
    print(f"\n{'='*60}")
    print(f"SEO Content Pipeline starting for: '{seed_keyword}'")
    print(f"{'='*60}\n")

    analysis = stage1_keyword_analysis(seed_keyword)
    outline = stage2_outline(analysis, target_word_count)
    draft = stage3_draft(analysis, outline, target_word_count)
    meta = stage4_meta(analysis, draft)
    jsonld = stage5_jsonld(meta, analysis, author)

    elapsed = time.time() - start

    # Validate JSON-LD is parseable
    try:
        schema_obj = json.loads(jsonld["schema_json"])
    except json.JSONDecodeError as exc:
        print(f"  Warning: JSON-LD parse failed ({exc}). Storing as raw string.")
        schema_obj = jsonld["schema_json"]

    bundle = {
        "keyword": seed_keyword,
        "analysis": analysis,
        "outline": outline,
        "draft": {
            "html_content": draft["html_content"],
            "word_count_estimate": draft.get("word_count_estimate", 0),
        },
        "meta": meta,
        "jsonld": schema_obj,
        "pipeline_stats": {
            "elapsed_seconds": round(elapsed, 1),
            "target_word_count": target_word_count,
        },
    }

    print(f"\n{'='*60}")
    print(f"Pipeline complete in {elapsed:.1f}s")
    print(f"Estimated word count: {draft.get('word_count_estimate', 'unknown')}")
    print(f"Meta title ({len(meta['meta_title'])} chars): {meta['meta_title']}")
    print(f"Meta desc  ({len(meta['meta_description'])} chars): {meta['meta_description']}")
    print(f"{'='*60}\n")

    return bundle


# ---------------------------------------------------------------------------
# Save bundle to disk
# ---------------------------------------------------------------------------
def save_bundle(bundle: dict, output_path: str) -> None:
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(bundle, f, indent=2, ensure_ascii=False)
    print(f"Bundle saved to: {output_path}")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    # Example run
    result = run_pipeline(
        seed_keyword="python logging best practices",
        target_word_count=1600,
        author="Jane Dev",
    )

    # Save the full bundle
    save_bundle(result, "content_bundle.json")

    # Print the HTML draft preview (first 400 chars)
    print("--- HTML Draft Preview ---")
    print(result["draft"]["html_content"][:400])
    print("...")

Sample Run Output

============================================================
SEO Content Pipeline starting for: 'python logging best practices'
============================================================

[Stage 1] Analyzing keyword: 'python logging best practices'
  Tokens: 187 in / 312 out | cache_read=0
[Stage 2] Generating outline...
  Tokens: 542 in / 487 out | cache_read=153
[Stage 3] Writing full draft (this may take 20-40 seconds)...
  Tokens: 981 in / 2847 out | cache_read=153
[Stage 4] Writing meta copy...
  Tokens: 364 in / 118 out | cache_read=153
[Stage 5] Generating JSON-LD schema...
  Tokens: 294 in / 287 out | cache_read=153

============================================================
Pipeline complete in 47.3s
Estimated word count: 1583
Meta title (58 chars): Python Logging Best Practices for Production Apps
Meta desc  (148 chars): Learn Python logging best practices that production
engineers actually use: structured logs, log levels, handlers,
and rotation strategies that scale.
============================================================

Bundle saved to: content_bundle.json

--- HTML Draft Preview ---
<h1>Python Logging Best Practices for Production Applications</h1>
<p>Good logging is the difference between debugging a production incident
in 10 minutes and spending 3 hours reading server output looking for a
clue. Python logging best practices have evolved significantly as teams
moved workloads into containers and distributed systems...

Bundle Output Structure

The content_bundle.json file has this top-level shape, which your CMS integration code reads:

{
  "keyword": "python logging best practices",
  "analysis": {
    "primary_keyword": "python logging best practices",
    "topic_summary": "...",
    "search_intent": "informational",
    "related_terms": ["python logging module", "structured logging python", ...],
    "suggested_h1": "Python Logging Best Practices for Production Apps",
    "content_angle": "..."
  },
  "outline": {
    "h1": "...",
    "intro_notes": "...",
    "sections": [
      {
        "h2": "Understanding Python's logging Module",
        "notes": "...",
        "subsections": [{"h3": "Log Levels Explained", "notes": "..."}]
      }
    ],
    "conclusion_notes": "..."
  },
  "draft": {
    "html_content": "<h1>...</h1><p>...</p>...",
    "word_count_estimate": 1583
  },
  "meta": {
    "meta_title": "Python Logging Best Practices for Production Apps",
    "meta_description": "Learn Python logging best practices...",
    "og_title": "Python Logging Best Practices Every Backend Engineer Should Know"
  },
  "jsonld": {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": "Python Logging Best Practices for Production Apps",
    "description": "Learn Python logging best practices...",
    "author": {"@type": "Person", "name": "Jane Dev"},
    "publisher": {"@type": "Organization", "name": "My Blog"},
    "datePublished": "2024-01-01",
    "articleSection": "Python Development"
  },
  "pipeline_stats": {
    "elapsed_seconds": 47.3,
    "target_word_count": 1600
  }
}

WordPress Integration: Publishing the Bundle

The bundle is designed to be consumed by a simple WordPress REST API call. Here is the integration layer that reads a bundle file and creates a draft post:

"""
wp_publisher.py - Post a content bundle to WordPress as a draft.
Requires: pip install requests python-dotenv
Env vars: WP_BASE_URL, WP_USERNAME, WP_APP_PASSWORD
"""

import json
import os
import requests
from dotenv import load_dotenv

load_dotenv()

WP_BASE = os.environ["WP_BASE_URL"].rstrip("/")
WP_USER = os.environ["WP_USERNAME"]
WP_PASS = os.environ["WP_APP_PASSWORD"]


def publish_bundle(bundle_path: str) -> dict:
    with open(bundle_path, "r", encoding="utf-8") as f:
        bundle = json.load(f)

    draft = bundle["draft"]
    meta = bundle["meta"]
    jsonld = bundle.get("jsonld", {})

    # Inject JSON-LD into the HTML content as a script tag
    jsonld_script = (
        f'<script type="application/ld+json">'
        f'{json.dumps(jsonld, indent=2)}'
        f'</script>'
    )
    post_content = draft["html_content"] + "\n" + jsonld_script

    post_data = {
        "title": meta["meta_title"],
        "content": post_content,
        "status": "draft",
        "meta": {
            "_yoast_wpseo_title": meta["meta_title"],
            "_yoast_wpseo_metadesc": meta["meta_description"],
        },
    }

    resp = requests.post(
        f"{WP_BASE}/wp-json/wp/v2/posts",
        auth=(WP_USER, WP_PASS),
        json=post_data,
        timeout=30,
    )
    resp.raise_for_status()
    result = resp.json()
    print(f"Created draft post ID {result['id']}: {result['link']}")
    return result


if __name__ == "__main__":
    import sys
    path = sys.argv[1] if len(sys.argv) > 1 else "content_bundle.json"
    publish_bundle(path)

pipeline.py 5 Claude calls structured output retry logic

content_bundle .json outline + draft + meta + schema

wp_publisher.py REST API POST injects JSON-LD sets Yoast meta

WordPress Draft Post

Human editor reviews before publishing

Figure 2: End-to-end data flow from the pipeline script through the JSON bundle to WordPress draft creation. The human review step is intentional.

AI SEO Content Generation: Common Pitfalls

Teams that have run AI SEO content generation pipelines in production for more than a few weeks tend to hit the same set of issues. Most are avoidable with the right guardrails.

Factual Hallucination in Statistics and Dates

Claude will produce plausible-sounding statistics if you ask for them without providing source material. The prompt in Stage 3 explicitly instructs the model not to invent statistics, but the better approach for fact-intensive topics is to pass a curated facts block as part of the prompt, then instruct Claude to use only those facts. The RAG pattern from Part 10 applies here: retrieve relevant passages from a trusted knowledge base and inject them into the draft prompt.

Meta Title Character Counts

Claude will occasionally return a meta title at 62 or 63 characters when you ask for under 60. Add a validation step after Stage 4 that checks len(meta['meta_title']) and re-calls if it exceeds 60. Two characters over feels minor but causes truncated ellipsis in Google SERPs, which hurts CTR.

HTML Escaping in the Draft

When Claude returns HTML inside a JSON string, angle brackets are sometimes escaped as &lt; and &gt;. Check your draft HTML with a quick regex before saving it. If the entire content is double-escaped, run it through html.unescape() once.

JSON-LD Validity

Always parse the schema_json string with json.loads() before writing it to disk or injecting it into a page. Invalid JSON-LD does not cause a visible error, but Google’s Rich Results test will ignore it. The pipeline includes a parse check; if it fails, log a warning and skip the schema rather than writing broken markup.

Over-Optimization

Stage 3’s prompt instructs Claude to weave in related terms naturally. In practice, if your related_terms list is too long or too similar to each other, the draft can read as repetitive. Keep the related terms list to six or seven items and choose terms with distinct semantic angles rather than near-synonyms of the primary keyword.

Rate Limits at Scale

Running the pipeline for 50 articles in parallel will hit Anthropic’s rate limits. The retry helper handles transient errors, but for batch jobs you should also implement a token bucket or use the anthropic.Batch API for Stage 3 drafts. Stage 1, 2, 4, and 5 are fast enough to run sequentially per article.

Cost and Latency Reference

Stage Model Avg input tokens Avg output tokens Approx cost (USD) Avg latency
1. Keyword analysis Haiku 200 320 $0.0001 1 to 3s
2. Outline Haiku 550 500 $0.0003 2 to 5s
3. Draft (1,500 words) Sonnet 1,000 2,800 $0.028 20 to 45s
4. Meta copy Haiku 380 120 $0.0001 1 to 2s
5. JSON-LD Haiku 300 290 $0.0001 1 to 3s
Total (1,500 words) Mixed 2,430 4,030 ~$0.029 25 to 60s

Cost estimates are based on public Anthropic pricing as of mid-2024. Prompt caching reduces the effective input token cost for the shared system prompt by approximately 90 percent on cache hits (see Anthropic’s prompt caching documentation), bringing the total cost per article below $0.025 at scale.

If you switch Stage 3 to Opus, the draft cost rises to roughly $0.15 per article. That is still cheap compared to freelance rates, but the 5x cost increase is rarely justified for standard informational content. For a systematic approach to that trade-off, see Part 27 on cost optimization and routing.

Extending the Pipeline

Adding a Fact-Check Stage

Insert a Stage 3.5 between draft and meta: send the draft to Haiku with a prompt asking it to identify any specific statistics, dates, or named claims that should be verified. Return a list of claims as structured output. Your human editor reviews this checklist before publishing. This costs about $0.002 per article and dramatically reduces the chance of publishing stale or invented data.

Multi-Language Output

Add a target_language parameter to each stage prompt. The pipeline works well for Spanish, French, German, and Portuguese with minimal prompt changes. For right-to-left languages, also pass an html_direction field in the draft tool schema so the output includes the correct dir="rtl" attributes.

Internal Linking Stage

After the draft is written, a sixth call can scan the HTML for anchor opportunities and suggest internal links from a list of existing posts you pass in. This is a natural extension of the structured output pattern: return a list of objects with anchor_text, suggested_url, and paragraph_index fields, then apply the links programmatically.

Image Brief Generation

A lightweight Haiku call after Stage 2 can generate a structured image brief for each H2 section: image type (diagram, screenshot, photo), subject description, alt text. Your design team or a text-to-image API consumes these briefs to produce visuals that are thematically aligned with the content structure.

What This Pipeline Cannot Do

Being clear about limitations is more useful than overselling capabilities. This pipeline does not replace the following:

  • Subject-matter expertise. A Claude draft about a highly technical topic (Kubernetes operators, compilers, financial derivatives) will be structurally sound but will miss nuance that only comes from deep practical experience. The pipeline is a first-draft accelerator, not a subject-matter expert.
  • Original research. If your content strategy depends on unique data, surveys, or case studies, that data still needs to come from you. The pipeline can structure and present your original research effectively, but it cannot generate it.
  • Brand voice without examples. The shared system prompt in this pipeline uses generic voice guidelines. For a strong brand voice, add 3 to 5 sample paragraphs from your best-performing content to the cached system prompt as style references.
  • Topical authority over time. Publishing AI-generated content without a coherent topical strategy will not build the cluster authority that competitive SERP rankings require. The pipeline is a production tool, not a strategy tool.

For guidance on building evals to measure output quality systematically, Part 24 on evaluation harnesses covers a practical approach you can adapt to content quality scoring.

Frequently Asked Questions

Does Google penalize AI-generated content?

Google’s current guidance focuses on content quality and usefulness, not on whether a human or an AI wrote it. Content that is thin, repetitive, or clearly optimized for search engines rather than readers can be penalized regardless of how it was produced. The pipeline’s multi-stage structure, which separates keyword research from drafting, is designed to produce content that serves reader intent first. Human review before publishing is still essential.

How accurate is the word count estimate in the bundle?

The word_count_estimate field is Claude’s self-reported estimate, which tends to be within 10 to 15 percent of the actual word count. For precise counts, strip HTML tags from the html_content field and count tokens or words in Python with len(content.split()) or the anthropic SDK’s count_tokens method.

Can I run multiple keywords in parallel?

Yes. The pipeline function is stateless, so you can run it with concurrent.futures.ThreadPoolExecutor for multiple keywords. Watch your rate limits: running 10 articles in parallel will spike your tokens-per-minute usage. Start with 3 to 5 concurrent runs and monitor the anthropic.RateLimitError response. The retry helper catches these, but a deliberate concurrency limit avoids unnecessary retries.

Why use five separate calls instead of one large call?

Three reasons. First, each stage produces an intermediate artifact you can inspect and correct before it propagates downstream. If the outline is wrong, you fix it before paying for a full draft. Second, each stage uses the cheapest model appropriate for its task, which cuts costs by roughly 40 percent compared to running everything on Sonnet. Third, shorter prompts with focused instructions produce more consistent structured output than a single massive prompt asking for everything at once.

How do I handle topics where Claude produces inaccurate content?

Pass authoritative source material into the Stage 3 prompt directly. Retrieve relevant passages from your knowledge base (see the RAG pattern in Part 10), prepend them as a “reference material” block with a clear instruction to use only these facts for specific claims, and add the fact-check stage described in the extension section above. For highly regulated topics (medical, legal, financial), this is not optional.

Can the pipeline handle different content types beyond blog articles?

Yes, with schema adjustments. For landing pages, remove the intro/conclusion structure from the outline tool and add conversion-focused sections. For product descriptions, add a specifications array to the draft schema. For email newsletters, replace the HTML body schema with a subject_line, preheader, and sections array. The orchestration pattern and retry logic remain unchanged across content types.

What is the best way to improve draft quality over time?

Track which articles perform well (time on page, scroll depth, organic CTR) and build a curated sample set of your top 10 to 20 performers. Add 2 to 3 excerpts from these samples to the cached system prompt as style references, clearly labeled “Example of high-quality content from this site.” The model will pattern-match your house style without explicit rules. Refresh this sample set quarterly as new top performers emerge.

See all articles in the AI in Production series.

Additional reading: Anthropic tool use documentation, Anthropic prompt caching guide, Google’s helpful content guidelines, Schema.org Article type reference.

MUASIF80 Avatar
Previous

Leave a Reply

Your email address will not be published. Required fields are marked *