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.
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.
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.
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
| Block | Word cap | What the prompt must force | How you check it |
|---|---|---|---|
| Hero headline | 12 | One promise, one audience | Contains a number or named thing |
| Hero subhead | 18 | Who it is for, in their words | Names the audience explicitly |
| Benefit block | 30 each | One proof point per block | Each maps to a brief proof item |
| Objection block | 30 each | Doubt stated before answered | Doubt appears in the brief list |
| Call to action | 5 | One verb, one outcome | Exactly 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. Addresponse_format={"type": "json_object"}to the call.KeyError: 'headline'— the model renamed your field, usually to something liketitle, because the prompt described the shape loosely. List the exact key names in the prompt and read values withhero.get("headline", "")while you are testing.TypeError: string indices must be integers—benefitscame back as one long string instead of a list of objects, sob['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.
Related guides
- Write System Prompts that Control Output Format — make the JSON shape stick on every call.
- Chain Prompts Together in Python — pass one block's output into the next block's prompt.
- Few-Shot Prompting in Python with Examples — show the model two headlines you like instead of describing them.
- Estimate OpenAI API Costs with Python — price fifteen calls per page before you run a hundred pages.
- Generate Meta Descriptions in Bulk with Python — write the search snippet once the page copy exists.