Content & Marketing

Write Landing Page Copy with Python and AI

Generate a landing page block by block in Python: capture the brief as a dict, cap each section's length, return JSON, and score three headline variants.

By the end of this guide you will have a Python script that turns a short brief into a complete landing page — hero headline, subhead, three benefit blocks, an objection-handling section, and one call to action — with each block generated by its own prompt, capped to its own length, and returned as JSON so it drops straight into a template. Expect about forty minutes to build it and roughly a dozen API calls per page you generate.

The reason to build it this way is simple. When you ask a model for "landing page copy", you get a slab of text that reads fine and cannot be edited in pieces: the headline is too long for your hero area, the benefits are four when your design has three, and fixing one paragraph means regenerating everything. Generating block by block gives you control at the level you actually work at.

This guide sits in AI Copywriting Workflows, alongside Generate Blog Posts with the OpenAI API and Bulk-Rewrite Product Descriptions with Python.

Prerequisites

You need Python 3.10 or newer, an OpenAI API key, and a folder to work in. If you have not isolated your packages yet, follow Create a Python Virtual Environment for AI first — it keeps this project's libraries away from the rest of your machine.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "python-dotenv>=1.0"

Put your key in a file called .env next to your script:

OPENAI_API_KEY=sk-your-key-here

Then add that file to .gitignore before you commit anything, so the key never reaches a public repository:

echo ".env" >> .gitignore

Step 1 — Capture the brief as a Python dict

Everything downstream depends on this one object. A brief is not a paragraph of background; it is five specific answers that the model is not allowed to guess. Write them as a dictionary, which is Python's name for a set of labelled values, and keep it at the top of your file where you can edit it in ten seconds.

The five fields are the audience (one named group, not "everyone"), the promise (one hard outcome, not three soft ones), the proof (facts you can defend in public), the objections (the actual reasons people leave), and the call to action (one, singular). If you leave a field vague, the model has nothing concrete to write from and falls back on the average of every landing page it has ever seen, which is exactly the copy you are trying to avoid.

BRIEF = {
    "product": "Sheetline",
    "audience": "freelance bookkeepers with 5 to 20 small-business clients",
    "promise": "close a client's monthly books in one sitting instead of three",
    "proof": [
        "imports statements from 40 UK and EU banks",
        "flags unmatched transactions before you open the ledger",
        "exports directly to Xero and QuickBooks",
    ],
    "objections": [
        "I already have a workflow that works",
        "my clients' banks are obscure",
        "moving data between tools always breaks something",
    ],
    "cta": "Start a free 14-day trial",
}

REQUIRED = ("product", "audience", "promise", "proof", "objections", "cta")


def check_brief(brief: dict) -> None:
    """Fail loudly before spending money on a call with a hole in it."""
    for field in REQUIRED:
        value = brief.get(field)
        if not value:
            raise ValueError(f"Brief field '{field}' is empty — fill it in first.")
    if len(brief["proof"]) < 2:
        raise ValueError("Give at least two proof points, or the copy will be vague.")


check_brief(BRIEF)
print("Brief looks complete.")

Run this and you should see Brief looks complete. Deliberately blank out proof and run it again — the error appears before any API call, which is the point. A guard like this costs three lines and stops you paying for output you will throw away.

One rule about the proof list: every item has to be something you would say out loud to a customer who might check. "Imports statements from 40 UK and EU banks" is checkable. "Saves you hours" is not, and a model handed that phrase will happily expand it into a paragraph of confident nonsense. The brief is the only place real facts enter this workflow, so vagueness here is the one mistake no later step can repair.

Step 2 — Give every block its own prompt and length cap

A landing page is five jobs, not one. The hero has to make a promise in about twelve words. The benefit blocks have to be parallel in shape and roughly equal in length. The objection section has to name the doubt out loud before answering it. Asking one prompt to juggle all of that guarantees compromise on each.

So describe the blocks as data — name, job, word cap — and loop. Each call sees the same brief, but only one instruction, and the max_tokens ceiling is set from the word cap so a rambling answer gets cut off rather than silently doubling your bill. This chaining pattern is covered more generally in Chain Prompts Together in Python.

How one brief becomes six separate landing page blocks A data flow in two rows: the brief dictionary feeds a block plan, which feeds one API call per block; each call returns a JSON object, three variants of it are scored, and the winners are rendered into a page template. Brief dict audience, promise Block plan five named sections One call each own cap, own rules JSON per block one field each Three variants scored on a list Template render hero through to CTA
The brief is written once and read by every call; each block travels its own path from prompt to scored variant to rendered slot in the template.
import json
import os
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads .env, which is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

SYSTEM = (
    "You write landing page copy for one named audience. "
    "Plain English. No superlatives, no hype adjectives, no invented facts. "
    "Use only the proof points given in the brief. "
    "Never exceed the word limit you are given."
)

BLOCKS = [
    {"name": "hero", "max_words": 30,
     "job": "One headline of at most 12 words stating the promise, then one subhead "
            "of at most 18 words naming the audience."},
    {"name": "benefits", "max_words": 90,
     "job": "Three benefit blocks. Each has a 4-word title and one sentence tied to a "
            "proof point from the brief."},
    {"name": "objections", "max_words": 90,
     "job": "Name each objection in the reader's own words, then answer it in one "
            "sentence using a proof point."},
    {"name": "cta", "max_words": 25,
     "job": "One button label of at most 5 words plus one reassuring line under it."},
]


def generate_block(brief: dict, block: dict) -> str:
    user = (
        f"Brief:\n{json.dumps(brief, indent=2)}\n\n"
        f"Write the '{block['name']}' block only.\n"
        f"Job: {block['job']}\n"
        f"Hard limit: {block['max_words']} words."
    )
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.7,
        max_tokens=block["max_words"] * 3,   # ~3 tokens per word leaves headroom
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": user},
        ],
    )
    return response.choices[0].message.content.strip()


for block in BLOCKS:
    print(f"\n--- {block['name']} ---")
    print(generate_block(BRIEF, block))

Two details matter here. temperature controls how much the model varies its wording; 0.7 gives you copy with some life in it, while 0.2 would give near-identical phrasing every run. If that dial is new to you, What Is Temperature in an LLM API? explains the trade-off. The max_tokens ceiling is a hard stop measured in tokens (chunks of roughly three-quarters of a word), so multiplying the word cap by three leaves room without letting a block run away.

Step 3 — Ask for JSON so each block lands in its own field

The output above is readable text, which means you still have to cut it apart before it can go anywhere. Ask for JSON instead and the model does the separating for you: headline in one field, subhead in another, benefits as a list of three objects. Setting response_format={"type": "json_object"} tells the OpenAI API to return only valid JSON, so you can hand the string straight to json.loads without stripping code fences.

Name the exact keys you want in the prompt. Models are cooperative about structure when you spell it out and inventive when you do not, and an invented key name breaks your template at render time.

Which JSON field fills which part of the rendered page Four rows mapping the JSON keys headline, subhead, benefits and cta on the left to the hero heading, hero paragraph, three benefit cards and button label on the right. headline string, max 12 words Hero heading one promise, no hype subhead string, max 18 words Hero paragraph names the audience benefits list of three objects Three cards title plus one line cta string, max 5 words Button label one action only
Every key you name in the prompt becomes one slot in the template, so the shape you ask for is the shape your page expects.
HERO_SCHEMA = (
    'Return JSON with exactly these keys: '
    '"headline" (string, at most 12 words), '
    '"subhead" (string, at most 18 words), '
    '"reason" (string, one sentence explaining why this headline suits the audience).'
)


def generate_json_block(brief: dict, schema: str, job: str) -> dict:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0.7,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": (
                f"Brief:\n{json.dumps(brief, indent=2)}\n\n"
                f"Task: {job}\n{schema}"
            )},
        ],
    )
    return json.loads(response.choices[0].message.content)


hero = generate_json_block(BRIEF, HERO_SCHEMA, "Write the hero block.")
print(hero["headline"])
print(hero["subhead"])

Use the same helper for the other blocks by swapping the schema string: benefits returns a list of three objects with title and body, objections returns a list of {"doubt": ..., "answer": ...} pairs, and cta returns button plus reassurance. If a parse ever fails anyway, Fix JSONDecodeError with AI API Responses in Python walks through the usual causes.

The reason field is worth keeping even though it never appears on the page. It makes the model state, in one sentence, why it wrote that headline for that audience — and reading three of those side by side tells you quickly whether it understood the brief or is pattern-matching on the product category. When the reasons are all interchangeable, your brief is too thin, not the model. Phrasing the schema this precisely is itself a system-prompt skill; Write System Prompts that Control Output Format covers the wording that makes a shape stick across a hundred calls.

Step 4 — Generate three variants and score them against a checklist

One headline is a coin flip. Three headlines, scored, is a decision. Because temperature is above zero, calling the same prompt three times gives three genuinely different attempts, and a short Python function can rank them before a human ever reads them.

Keep the checklist mechanical: does the line contain a number or a named thing, is it free of superlatives, and does it fit the word cap. Those three rules catch most of the difference between copy that sounds like a brochure and copy that sounds like a person who knows the product. What the script cannot judge is whether a claim is true, which is why it hands the shortlist back to you.

The checklist each generated variant has to pass A decision tree: each variant is tested for a concrete detail, then for superlatives, then for the word cap, with a named repair on the right for every failure and a ship decision at the bottom. Specific? holds a number or name Add the proof or drop the claim Hype-free? no best, no seamless Delete the boast keep the fact Inside the cap? word count vs limit Regenerate it tighter limit, again You pick one only you know truth no no no yes yes yes
The script can only test the three mechanical rules on the left path; the truth of a claim is the one test that stays with you.
import re

HYPE = re.compile(
    r"\b(best|ultimate|revolutionary|amazing|world[- ]class|"
    r"game[- ]changing|seamless|effortless|cutting[- ]edge)\b",
    re.IGNORECASE,
)


def score(text: str, max_words: int) -> tuple[int, list[str]]:
    """Return a 0-3 score plus a note for every rule the text fails."""
    notes: list[str] = []
    points = 0

    if re.search(r"\d", text):
        points += 1
    else:
        notes.append("no number or figure")

    hit = HYPE.search(text)
    if hit is None:
        points += 1
    else:
        notes.append(f"hype word: {hit.group(0)}")

    words = len(text.split())
    if words <= max_words:
        points += 1
    else:
        notes.append(f"{words} words, cap is {max_words}")

    return points, notes


def variants(brief: dict, schema: str, job: str, n: int = 3) -> list[dict]:
    return [generate_json_block(brief, schema, job) for _ in range(n)]


options = variants(BRIEF, HERO_SCHEMA, "Write the hero block.")
ranked = sorted(
    options,
    key=lambda v: score(v["headline"], 12)[0],
    reverse=True,
)

for i, option in enumerate(ranked, 1):
    points, notes = score(option["headline"], 12)
    print(f"\n{i}. [{points}/3] {option['headline']}")
    print(f"   sub: {option['subhead']}")
    if notes:
        print(f"   fails: {', '.join(notes)}")

Run it and you get three headlines with their scores and their specific failings, ordered best first. Read all three anyway. A two-out-of-three headline that states a fact you can prove beats a three-out-of-three headline built on a number you invented, and no regular expression can tell those apart.

Step 5 — Render the winners into a page template

Once you have picked a variant per block, assembly is mechanical. Keep the template in a plain string with named placeholders and fill it with str.format, which swaps each {name} for the matching value. No template library is needed for a single page.

from pathlib import Path

TEMPLATE = """<section class="hero">
  <h1>{headline}</h1>
  <p>{subhead}</p>
  <a class="cta" href="/signup/">{cta}</a>
</section>
<section class="benefits">
{benefit_html}
</section>
"""


def render(hero: dict, benefits: list[dict], cta: str, out: str = "landing.html") -> str:
    benefit_html = "\n".join(
        f"  <article><h3>{b['title']}</h3><p>{b['body']}</p></article>"
        for b in benefits
    )
    html = TEMPLATE.format(
        headline=hero["headline"],
        subhead=hero["subhead"],
        cta=cta,
        benefit_html=benefit_html,
    )
    Path(out).write_text(html, encoding="utf-8")
    return out


chosen = ranked[0]
sample_benefits = [
    {"title": "Bank imports that hold", "body": "Statements from 40 UK and EU banks land clean."},
    {"title": "Mismatches caught early", "body": "Unmatched transactions surface before you open the ledger."},
    {"title": "Exports your tools accept", "body": "One click to Xero or QuickBooks, no CSV surgery."},
]
print("Wrote", render(chosen, sample_benefits, BRIEF["cta"]))

Open landing.html in a browser and you have a real page skeleton. Swap sample_benefits for the generated benefits list once you are happy with it, and drop the markup into whatever site builder you already use.

Two habits make this worth rerunning. Save the chosen variants to a JSON file next to the HTML, so a week later you can regenerate the page without regenerating the copy. And keep the brief in the same folder as the script rather than pasting it in each time — when the promise changes, you edit one dictionary and rerun, and every block moves with it. That is the real payoff of doing this in Python instead of a chat window: the page becomes a function of a brief you can version, diff, and reuse.

Block and cap quick reference

BlockWord capWhat the prompt must forceHow you check it
Hero headline12One promise, one audienceContains a number or named thing
Hero subhead18Who it is for, in their wordsNames the audience explicitly
Benefit block30 eachOne proof point per blockEach maps to a brief proof item
Objection block30 eachDoubt stated before answeredDoubt appears in the brief list
Call to action5One verb, one outcomeExactly one link or button

Troubleshooting

  • json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the model wrapped its JSON in a Markdown code fence, so the string starts with a backtick rather than a brace. Add response_format={"type": "json_object"} to the call.
  • KeyError: 'headline' — the model renamed your field, usually to something like title, because the prompt described the shape loosely. List the exact key names in the prompt and read values with hero.get("headline", "") while you are testing.
  • TypeError: string indices must be integersbenefits came back as one long string instead of a list of objects, so b['title'] fails. Say "a JSON array of exactly three objects, each with the keys title and body" and regenerate.
  • openai.RateLimitError: Error code: 429 — three variants across five blocks is fifteen calls in a few seconds, which can trip a new account's limit. Add a short pause between calls, or follow Fix the 429 Rate-Limit Error in Python for a retry loop with backoff.

When to use this vs. alternatives

  • Use block-by-block generation when the page has a fixed design with slots to fill — a hero, three cards, a button. You get length control per slot and can regenerate one weak block for the price of one call.
  • Use a single long prompt when you want a rough draft to react to and do not yet know the page structure. It is cheaper and faster, and you will rewrite most of it by hand anyway.
  • Use a rewrite script instead when the copy already exists and only the tone or length is wrong. The pattern in Bulk-Rewrite Product Descriptions with Python takes existing text as its input, which keeps your real claims intact by default.

The honest limit of this workflow is worth stating plainly. The model can write copy, choose a rhythm, and hold a word count. It cannot know that your bank import list is 40 institutions rather than 400, that your trial is 14 days, or that the objection your buyers actually raise is about migration rather than price. Those facts come from you, through the brief, and every sentence of quality in the output traces back to how honestly you filled those five fields. Treat the script as a fast, tireless drafter and keep the judgement — and the proof — on your side of the desk. When you are ready to reuse the same brief across other formats, the newsletter pattern in Generate Email Newsletters with Python and AI reads from a near-identical dictionary. Back to AI Copywriting Workflows.

Frequently asked questions

Can AI write a whole landing page in one prompt?

It can produce something page-shaped, but the result is usually vague and impossible to edit in pieces. Asking for one section at a time gives you a length cap per section, a separate retry when one part is weak, and text that drops straight into a template field. Section-by-section takes a few more API calls and saves far more editing.

What should go in the brief before I call the model?

Five things: who the page is for, the single hard promise you are making, two or three proof points you can actually defend, the objections that stop people buying, and one call to action. If any of those is blank, the model fills the gap with generic marketing language, because it has nothing real to work from.

How do I stop the model writing hype like world-class or revolutionary?

Ban the words in the system message and then check the output in Python with a regular expression. Instructions alone leak; a deterministic check catches what leaks. Score each generated variant against a short list of rules and print the failures so you can regenerate just that block rather than the whole page.

Why ask for JSON instead of plain text?

JSON puts every block in its own named field, so headline, subhead, benefits, and call to action arrive already separated. You can then drop them into an HTML template with one line of code instead of splitting a wall of text with fragile string parsing. Set response_format to json_object and the OpenAI API guarantees parseable output.

How many headline variants should I generate?

Three is the practical number. One gives you nothing to compare, and ten makes the choice tiring without improving quality. Generate three per block at a moderate temperature, score them automatically, then pick the winner yourself. The script narrows the field; you make the final call because only you know which proof point is true.