We asked Claude Code to draft one marketing email for a fictional cafe, using only a short file of approved facts. Then a few lines of code checked selected facts and formatting before a person would read it. The draft passed 11 of 11 checks, and a bad draft we planted on purpose failed 2. Sending stays with a person.
This post covers drafting only. We didn't send anything, and we have no open rates or sales to show from it. Those numbers only exist after a real, approved campaign goes out.
The campaign and who it's for
The shop is Kopi & Crumb, a made-up cafe. The campaign is a reminder to customers who already buy whole cakes, with a pickup offer on pandan chiffon.
We described the audience in three plain lines:
- bought a whole cake in the last 6 months
- opted in to the shop's email letters
- picks up in store, not delivery
There are no names or email addresses in it. The model never needs the list itself to write the email. Keep the real contact list away from the AI, in whatever tool does your sending.
Give it a facts file
The approved facts go in facts.json. Here is an excerpt with the offer fields. The full file also names the fictional shop and lists the audience lines above.
{
"offer": "Pre-order a whole pandan chiffon cake for pickup and pay RM38 instead of RM45",
"price_now": "RM38",
"price_was": "RM45",
"expiry": "31 October",
"pickup_hours": "9:00-16:00, Monday to Saturday",
"order_link": "https://example.com/kopi-and-crumb/preorder",
"unsubscribe_line": "{{unsubscribe_link}}"
}Then one Claude Code call in headless mode, with every tool switched off. This is the exact command from our run:
claude -p "Write one marketing email as Markdown for the shop in facts.json below. Audience: $(python3 -c 'import json;print("; ".join(json.load(open("facts.json"))["audience"]))').
Use only these facts, word for word where they are numbers or dates, and add no other prices, dates, discounts, claims or urgency:
$(cat facts.json)
Format: a line 'Subject: ...', a line 'Preview: ...', then the body (under 120 words), then the order link, then a last line exactly: Unsubscribe: {{unsubscribe_link}}
Output only the email." --model haiku --tools "" --setting-sources "" --strict-mcp-config --output-format text < /dev/null > campaign-draft.md--tools "" is the important part. With no tools, the model can't send, schedule or contact anyone. All it can do is return text, which lands in a file.
The draft and the checks we ran
Here's what came back, unedited:
Subject: Your next whole pandan chiffon, RM38 until 31 October
Preview: Pre-order a whole pandan chiffon cake for pickup and pay RM38 instead of RM45.
Hi there,
Thanks for choosing Kopi & Crumb for a whole cake recently. For your next one, pre-order a whole pandan chiffon cake for pickup and pay RM38 instead of RM45. This offer runs until 31 October.
Pick up in store, Monday to Saturday, 9:00-16:00.
Order here: https://example.com/kopi-and-crumb/preorder
Kopi & Crumb
Unsubscribe: {{unsubscribe_link}}It reads fine. To check it, check.py compares the draft to facts.json and writes fact-check.csv. It looks for the right price and old price, no other RM amounts, the right expiry and no other dates, the pickup hours, the exact order link, the unsubscribe line as the last line, a subject and preview, and no percent claims. The draft passed all 11.
Those checks have limits. They catch RM amounts, and dates written as a day and a full month name. They require the approved link but don't reject extra links. The hours check looks for two time strings. The 11th row, "nothing sent", records that the model had no tools. It isn't a test.
Then we planted a bad draft. Same email, but the body said "pay RM35, that is 20% off". The checker failed it on 2 lines: an RM amount that isn't in the facts, and a percent claim.
The gap our own check missed
The bad draft still passed "price now". RM38 was still in the subject and the preview, so the check found it, even though the body said RM35. It only got caught because a second check looks for any RM amount that shouldn't be there.
So write checks for what must not appear as well as what must, and when a check passes, read what it actually tested.
Consent, stop rules and who presses send
The AI part ends at the draft. Before anything goes out, a person should confirm three things:
- Everyone on the list asked to hear from you. Our audience line says "opted in" for a reason.
- Every email has a working way to stop. Test the unsubscribe link in your sending tool. Our checker only confirms the placeholder is on the last line.
- One named person approves the exact email and the exact list, then presses send in your email tool.
A campaign to a list is a different job from a one-to-one reply to a customer who wrote in. Don't let a tool built for one do the other.
Keep a record before you send
Save three files with every campaign: the facts file, the draft as the AI wrote it, and the final version a person approved. If someone edits the price or the date by hand, the difference shows up. If a customer later asks "you said RM38?", you can show what was approved and why. Your sending tool's own record shows what actually went out.
If you later connect an email account to an agent, keep drafting and sending as separate steps. Gmail's API has separate methods for drafts and sending, but its gmail.compose permission allows both. To keep an agent to drafts, your app has to expose only the draft step and keep sending behind a separate approval.
Our guide on agents for an online business in 30 days shows where a draft step like this fits among the other agents in a small business.
Paste this into your agent
Write one marketing email for [your business] as Markdown, using only
the facts in facts.json: [paste your offer, prices, expiry date, hours,
link and unsubscribe placeholder]. Add no other prices, dates,
discounts, percentages or urgency. Format: Subject line, Preview line,
body under 120 words, the link, and the unsubscribe line last. Don't
send or schedule anything. Then write check.py that compares the draft
to facts.json. It should go further than a basic demo: require the
approved prices and expiry date, reject any price or percentage not in
the facts, reject any other date in the formats it supports, reject any
link other than the approved one, state exactly which price and date
formats it can recognise, and fail if the unsubscribe line isn't last.
Run it on the draft, and on a separate bad copy with a wrong price and a
made-up percentage. Report the real results and anything the checks
can't catch.