Writing the same kind of copy over and over by hand is slow and uneven. You spend an afternoon on twenty product descriptions, the tenth one drifts in tone, and by Friday you cannot remember which version you actually published. An AI copywriting workflow fixes that by turning copy into a repeatable recipe: you describe the job once, a Python script does the typing, and a quality check catches the weak drafts before anyone reads them.
This guide is written for marketers, founders, and creators who are comfortable copying and pasting code but do not write Python for a living. You will build a small, honest pipeline that takes a copy brief (the facts and instructions for one piece of writing), turns it into a prompt, asks an AI model for a draft, and runs that draft through a review loop. Everything here is complete and runnable on Python 3.10 or newer, using the official openai SDK and httpx for any extra HTTP calls. This section sits inside the larger AI Content Creation & Marketing Automation track.
By the end you will have a script you can point at a spreadsheet of products or topics and walk away. Along the way you will learn how to keep the tone consistent, how to stop the model from inventing claims, and how to know when a draft is good enough to ship.
Who needs this and what it solves
You need a copywriting workflow when the writing is repetitive but not identical. One landing page is a one-off; fifty meta descriptions, a weekly newsletter, or a catalogue of product blurbs is a workflow. The pattern is always the same: real facts go in, on-brand copy comes out, and a check makes sure nothing embarrassing slips through.
The trap most people fall into is treating an AI chat window as the whole solution. You paste a request, get a draft, tweak it, copy it somewhere, and repeat. That works for one or two items and falls apart at twenty, because nothing is recorded, nothing is consistent, and nothing stops a bad draft from going out. A workflow is the opposite: the instructions live in code, every item is built the same way, and a quality gate sits between the model and your audience. The reward is not just speed. It is consistency you can trust and an audit trail you can point to when someone asks why a piece of copy says what it says.
There is a second, quieter benefit. Once the brief is a structured object rather than a sentence you typed from memory, you stop relying on yourself to remember the rules. Brand voice, length limits, and forbidden claims all become fields and checks that apply automatically. New team members can run the same script and get the same standard of output, which is the difference between a personal habit and a system the business owns.
A good rule of thumb: if you would copy and paste roughly the same prompt more than five times, it belongs in a script. Below that, the manual approach is fine. Above it, the time you spend wiring up the workflow pays for itself within the first batch, and keeps paying every time you run it again.
The diagram below shows the loop you are about to build. A brief becomes a prompt, the prompt produces a draft, the draft passes through a quality gate, and anything that fails goes back for another pass. That feedback loop is the whole point, so keep it in mind as you read.
Prerequisites
You need Python 3.10 or newer and an OpenAI API key. To get a key, create an account at the OpenAI platform, open the API keys page, and generate one. If the idea of API keys is new, the Understanding LLM APIs section explains what they are and how billing works.
First, create an isolated project folder with its own virtual environment so these packages do not collide with anything else on your machine. A virtual environment is just a private box for one project's dependencies.
mkdir copy-workflow && cd copy-workflow
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install openai httpx python-dotenv
Now store your API key. Create a file named .env in the project folder:
OPENAI_API_KEY=sk-your-real-key-here
Immediately add .env to your .gitignore so the key never lands in version control or a public repository:
echo ".env" >> .gitignore
That last step matters. A leaked key can run up real charges before you notice. With the environment ready, you can write the four steps of the workflow.
Step 1: Capture a reusable copy brief and load your key
A brief is the structured set of facts and instructions for one piece of copy. Keeping it in a small Python data structure means every draft is built from the same fields, so the output stays consistent. Use a dataclass, which is Python's built-in way to bundle named fields together.
import os
from dataclasses import dataclass
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv() # reads OPENAI_API_KEY from your .env file
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
@dataclass
class CopyBrief:
product: str # what you are writing about
audience: str # who will read it
tone: str # e.g. "friendly", "confident", "playful"
key_facts: str # real, true facts the model must use
channel: str # e.g. "product page", "email subject line"
brief = CopyBrief(
product="CloudNote, a note app that syncs across devices",
audience="busy freelancers",
tone="friendly and reassuring",
key_facts="syncs in under 2 seconds; works offline; free for one device",
channel="product page intro paragraph",
)
The key_facts field is your guardrail against invented claims. Whatever you put there is what the model is allowed to say about features. Leave it vague and the model will fill gaps with plausible-sounding fiction, so be specific and truthful.
Why a dataclass and not a plain dictionary? Two reasons that matter once you have more than a handful of items. First, every brief is forced to have the same fields, so you cannot accidentally forget tone on one product and not another. Second, your code editor can autocomplete brief.audience, which catches typos before you run anything. If you are loading briefs from a spreadsheet later, each row maps cleanly onto these fields, so the structure you choose here is the structure your whole pipeline speaks.
Keep the brief small and stable. The temptation is to add a dozen fields for every edge case, but a brief that nobody can fill in quickly stops getting used. Five clear fields that cover audience, tone, facts, product, and channel handle the vast majority of marketing copy. Add a field only when you find yourself repeatedly cramming the same extra instruction into key_facts where it does not belong.
Step 2: Turn the brief into a clear prompt
A prompt has two halves. The system message sets the role and rules and stays constant; the user message carries the specific brief. Splitting them this way keeps your brand rules in one place while the details change per item. For a deeper look at writing instructions that behave predictably, see Prompt Engineering Basics.
def build_messages(brief: CopyBrief) -> list[dict]:
system = (
"You are a senior copywriter. Write clear, specific marketing copy. "
"Use ONLY the facts provided; never invent features, prices, or claims. "
"Avoid hype words like 'revolutionary' or 'guaranteed'. "
"Return only the copy, with no preamble or quotation marks."
)
user = (
f"Write a {brief.channel} for {brief.product}.\n"
f"Audience: {brief.audience}.\n"
f"Tone: {brief.tone}.\n"
f"Facts you may use: {brief.key_facts}.\n"
f"Keep it under 80 words."
)
return [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
Notice how the rules ("use only the facts", "avoid hype words") live in the system message. You write them once and every draft inherits them. The user message is just the filled-in brief.
This split is the single most useful habit in the whole workflow. Think of the system message as your style guide and the user message as the work order. When you decide next month that all copy should end with a question, or that one particular word is off-limits, you change the system message in one place and every future draft obeys. If those rules were tangled into each user prompt, you would be editing them everywhere and missing some.
Two small details earn their keep here. Telling the model to "return only the copy, with no preamble or quotation marks" stops the chatty wrappers like Sure, here is your copy: that you would otherwise have to strip out by hand. And the explicit word limit ("under 80 words") gives the model a target it can actually hit, rather than leaving length to chance. Vague prompts produce vague results; specific constraints produce copy you can use as-is. If you write a lot of these, the Prompt Engineering Templates for Marketers guide collects ready-made patterns you can drop straight into the system message.
Step 3: Generate a draft with the OpenAI SDK
Now send the messages to the model. The temperature setting controls how creative the model is: lower means steadier and more predictable, higher means more varied. For copy you want to review at scale, a middle value around 0.6 is a sensible default.
def generate_copy(brief: CopyBrief, temperature: float = 0.6) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=build_messages(brief),
temperature=temperature,
max_tokens=200, # caps length so costs stay predictable
)
return response.choices[0].message.content.strip()
draft = generate_copy(brief)
print(draft)
The draft text lives at response.choices[0].message.content. That path looks fussy, but it is the standard shape of an OpenAI chat response: a list of choices, each holding a message with content. To learn how to read every other field and track token usage, follow Generate Blog Posts with the OpenAI API.
A word on max_tokens, because it surprises people. A token is roughly three-quarters of a word, so 200 tokens is about 150 words of output. Setting this cap does two things: it stops a runaway response from costing more than you expect, and it gives you a hard ceiling that pairs with the word limit in your prompt. If your drafts come back truncated mid-sentence, the cap is too low for the length you asked for, so raise it. If you are generating short blurbs, lowering it keeps your bill tight across a large batch.
Temperature deserves a second look too, because it is the dial you will reach for most. At a low setting the model plays it safe and tends to produce similar phrasing each time, which is ideal when you want predictable, easily reviewed copy. Turn it up and the model takes more risks with word choice, which is exactly what you want when you ask for three different headline options and pick the best. A practical trick is to generate one steady draft at low temperature for the body and several higher-temperature variants for the headline, then choose. There is no single correct value, only the value that fits the job in front of you.
Step 4: Add a quality check and an edit loop
A draft you never read is a risk. The quality gate is a small function that scores the draft against simple, honest rules and either accepts it or sends it back. Here the loop regenerates up to a few times before giving up and flagging the item for a human.
import re
BANNED = re.compile(r"\b(guaranteed|risk-free|100%|best ever)\b", re.IGNORECASE)
def passes_checks(text: str, max_words: int = 90) -> tuple[bool, str]:
if BANNED.search(text):
return False, "contains a banned hype phrase"
if len(text.split()) > max_words:
return False, "too long"
if len(text) < 30:
return False, "too short or empty"
return True, "ok"
def generate_with_retry(brief: CopyBrief, attempts: int = 3) -> str:
for attempt in range(1, attempts + 1):
draft = generate_copy(brief, temperature=0.6)
ok, reason = passes_checks(draft)
if ok:
return draft
print(f"Attempt {attempt} rejected: {reason}")
raise ValueError("No draft passed the quality check; flag for human review.")
This is the loop from the diagram. Passing drafts come straight back; failing ones trigger another attempt with the same brief. When the model cannot produce something acceptable, the script stops and tells you, which is far safer than silently publishing weak copy.
The checks here are deliberately simple, and that is the point. You do not need a machine-learning classifier to catch the most common problems. A short list of banned phrases stops the worst hype, a word count keeps length honest, and a minimum length catches empty or broken responses. These three rules will reject the large majority of genuinely bad drafts while almost never rejecting a good one. Start here, watch what slips through over your first few real batches, and add a rule only when you see a real failure it would have caught.
When you do add rules, think in terms of flag versus block. Some problems, like a banned legal phrase, should hard-block the draft and force a rewrite. Others, like a draft that is slightly long, might only deserve a flag that a human glances at. Mixing the two keeps the loop from rejecting copy that is fine in spirit but imperfect in detail. The version above blocks everything, which is the strict default; loosening specific checks to flags is a sensible upgrade once you trust the pipeline. Either way, the rule that should never be optional is the human fallback: when automation runs out of attempts, a person decides, and nothing reaches the public on autopilot.
Parameter reference
These are the settings you will adjust most often when calling the model. The first three belong to chat.completions.create; the last two are your own knobs from the script above.
| Parameter | Type | Default | Effect |
|---|---|---|---|
model | str | gpt-4o-mini | Which model writes the copy. gpt-4o-mini is cheap and fast; gpt-4o is stronger for nuanced pieces. |
temperature | float | 0.6 | Creativity. Near 0.3 for steady, reviewable copy; near 0.9 for varied options to pick from. |
max_tokens | int | 200 | Hard cap on output length. Lower it to control cost; raise it for long-form drafts. |
attempts | int | 3 | How many times the edit loop retries before flagging an item for a human. |
max_words | int | 90 | Length limit your quality check enforces on the draft. |
Troubleshooting
These are the errors you are most likely to hit, with the exact message, the cause, and a one-line fix.
AuthenticationError: Incorrect API key provided— Your key is missing or wrong. Confirm.envholdsOPENAI_API_KEY=sk-...and thatload_dotenv()runs before you create the client. See Fix the 401 Unauthorized Error in OpenAI Python.RateLimitError: 429 Too Many Requests— You sent calls faster than your tier allows. Add a shorttime.sleep(1)between items or follow Fix the 429 Rate-Limit Error in Python.AttributeError: 'NoneType' object has no attribute 'strip'— The model returned no content, somessage.contentisNone. Guard with(response.choices[0].message.content or "").strip().openai.APIConnectionError— Your machine could not reach the API, usually a network blip or proxy. Retry after a moment, and check a corporate firewall is not blockingapi.openai.com.ModuleNotFoundError: No module named 'openai'— The package is not installed in the active environment. Runpip install openaiagain after confirming your virtual environment is activated.- Drafts ignore your facts and invent features — Your
key_factsis too thin or the system rule is buried. Make the facts concrete and keep the "use ONLY the facts provided" rule in the system message.
Worked example: generate copy for a list of products
This script ties the four steps together. It reads a small list of products, generates one piece of copy for each through the quality loop, and writes the approved results to a CSV you can hand to anyone. Save it as run_copy.py and run it with python run_copy.py.
import csv
import os
import re
import time
from dataclasses import dataclass, asdict
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv() # load OPENAI_API_KEY from .env
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
BANNED = re.compile(r"\b(guaranteed|risk-free|100%|best ever)\b", re.IGNORECASE)
@dataclass
class CopyBrief:
product: str
audience: str
tone: str
key_facts: str
channel: str = "product description"
def generate_copy(brief: CopyBrief, temperature: float = 0.6) -> str:
system = (
"You are a senior copywriter. Use ONLY the facts provided; "
"never invent features or claims. Avoid hype words. Return only the copy."
)
user = (
f"Write a {brief.channel} for {brief.product}. "
f"Audience: {brief.audience}. Tone: {brief.tone}. "
f"Facts: {brief.key_facts}. Keep it under 80 words."
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "system", "content": system},
{"role": "user", "content": user}],
temperature=temperature,
max_tokens=200,
)
return (resp.choices[0].message.content or "").strip()
def good_enough(text: str) -> bool: # the quality gate
return bool(text) and not BANNED.search(text) and len(text.split()) <= 90
def write_one(brief: CopyBrief, attempts: int = 3) -> str:
for _ in range(attempts):
draft = generate_copy(brief)
if good_enough(draft):
return draft
time.sleep(1) # brief pause before retry
return "[FLAGGED FOR HUMAN REVIEW]"
briefs = [
CopyBrief("CloudNote app", "freelancers", "friendly",
"syncs in 2 seconds; works offline; free for one device"),
CopyBrief("BrewBot grinder", "home coffee fans", "confident",
"40 grind settings; quiet motor; 2-year warranty"),
]
with open("copy_output.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["product", "copy"])
for brief in briefs:
result = write_one(brief)
writer.writerow([brief.product, result])
print(f"Done: {brief.product}")
Swap the briefs list for rows read from your own spreadsheet and you have a copy machine. To scale this to a full catalogue with the same structure, see Bulk-Rewrite Product Descriptions with Python.
Next steps
You now have the core loop. Here is a sensible order to build on it:
- Apply the same pattern to long-form writing in Generate Blog Posts with the OpenAI API.
- Scale the worked example to a whole catalogue with Bulk-Rewrite Product Descriptions with Python.
- Repurpose drafts into a recurring send with Generate Email Newsletters with Python and AI.
- Feed real search terms into your briefs using SEO Keyword Research with Python, then push finished copy out with Automated Social Media Posting.
Back to AI Content Creation & Marketing Automation.