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:

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.

JSON
{
  "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:

BASH
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:

TEXT
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.

The AI part ends at the draft. Before anything goes out, a person should confirm three things:

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

TEXT
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.