AI-written outreach should read like the recipient wrote it: the quality bar we set for samples
Table of Contents
To be clear up front: we have not sent a single one of these emails, and the program contains no code that sends email. This post is about a process design and the quality bar we set for its samples.
The idea is simple. Instead of a self-introduction, attach something our products produced that the recipient can actually use. We designed three kinds of samples. The MindThread one is three Threads drafts written in the recipient's own voice. The SEO/GEO one is three findings from a scan of their public pages, each with a fix they can make themselves in about ten minutes. The UltraGrowth one is what we saw of their site in AI search, plus a one-page summary.
The first version produced five samples. We asked a reviewer to play the owner of each business and score the email; the average was 5.7. The problems were typical: the opener quoted their homepage tagline, the drafts were website copy moved onto Threads, and the findings were generic scanner lines like "no h1, images missing alt." If you received one, you would probably delete it too.
So we set four gates.
Gate 1: Build a brand brief first, and say what you couldn't get
Before the model writes anything, each recipient gets a brand brief with six fixed fields: positioning, audience, voice, what they sell, what they are pushing lately, and visible pain points. Every line needs a source.
Data comes only from public places, read-only:
- 3 to 5 pages of the website (home, about, courses or services, and so on), GET only, raw HTML saved.
- Threads on the web shows no posts without logging in, so we use Meta's official API to read the latest 15 posts, read-only.
- If the site has an RSS feed we take the latest 10 items; if they have a YouTube channel, the latest videos.
- Without logging in, Instagram and Facebook only show follower counts and a bio, not posts. The brief says so plainly instead of pretending we read them.
Research depth has a bar too: at least 3 website pages and at least 10 recent items. One of the five had no Threads account and a Facebook page behind login, so recent content was zero. We didn't pad it; the brief records why, and recent activity is judged from the site's news page only.
The opener got a new rule as well. No more homepage taglines. Instead, mention one specific thing they did in the last 30 days, with the date and a short verbatim quote, plus one observation they may have missed. For example, a site's news previews showing a chunk of code, or a brand that had just uploaded several videos none of its recent posts mentioned.
Gate 2: Compute the voice with code, don't let the model guess
"Write in their voice" does almost nothing for a model. It writes whatever it imagines a friendly small business sounds like. So we compute numbers from the recipient's own posts and hand the numbers to the model:
// Voice stats computed from the recipient's own posts (simplified)
const EMOJI = /\p{Extended_Pictographic}/gu
export function voiceStats(posts) {
const texts = posts.map((p) => p.text || '').filter(Boolean)
if (!texts.length) return null
const n = texts.length
const count = (re) => texts.reduce((a, t) => a + (t.match(re) || []).length, 0)
const chars = texts.map((t) => [...t.replace(/\s+/g, '')].length)
return {
posts: n,
avgChars: Math.round(chars.reduce((a, b) => a + b, 0) / n),
avgLines: +(texts.reduce((a, t) => a + t.split('\n').filter((l) => l.trim()).length, 0) / n).toFixed(1),
emojiPerPost: +(texts.flatMap((t) => t.match(EMOJI) || []).length / n).toFixed(1),
hashtagsPerPost: +(count(/#[^\s#]+/g) / n).toFixed(1),
// Chinese has a plain "you" (你), a feminine "you" (妳) and a polite "you" (您)
pronouns: { 我: count(/我(?!們)/g), 我們: count(/我們/g), 你: count(/你/g), 妳: count(/妳/g), 您: count(/您/g) },
questions: count(/[??]/g),
}
}
Take one of the brands: its recent posts averaged under 100 characters and six or seven lines, with almost no emoji, nearly always the feminine "you", never the polite one, and a question in every post. So the drafts follow that: one sentence per line, addressing readers with the feminine "you", ending on a question.
The second problem was a surprise. After the first rewrite, the reviewer's biggest complaint was "you just reposted our post from yesterday." To sound like them, the model simply copied them. So we added a reverse check: if a draft shares 18 or more consecutive characters with any post they already published, it goes back for a rewrite.
// Longest run of identical consecutive characters between two texts (simplified; one Chinese character counts as one)
export function longestCommonRun(a, b) {
const x = [...a], y = [...b]
let best = 0
let prev = new Array(y.length + 1).fill(0)
for (let i = 1; i <= x.length; i++) {
const cur = new Array(y.length + 1).fill(0)
for (let j = 1; j <= y.length; j++) {
if (x[i - 1] === y[j - 1]) { cur[j] = prev[j - 1] + 1; if (cur[j] > best) best = cur[j] }
}
prev = cur
}
return best
}
// Usage: reject the draft if longestCommonRun(draft, anyPublishedPost) >= 18
Finally, drafts should pick up something they are pushing but haven't talked about on Threads yet, such as a tutorial on their site or a video they just uploaded. Each draft carries one line explaining why it was written that way, pointing to a specific date and post.
Gate 3: A source for every fact, re-checked by code
This gate follows the same idea as the faithfulness check we built for our in-app AI assistant: every sentence the model writes has to trace back to the raw material.
Each sample has a fact table that maps every sentence about the recipient to a source URL and a verbatim snippet. The pre-send check does the following:
- Re-checks the quote in the opening line against the saved pages.
- Confirms each fact snippet in the drafts appears verbatim on a public page.
- Sends back any draft with a number that isn't in the source, including numbers written as Chinese characters.
- For website findings, attaches the saved curl output, the ten-minute fix, and ready-to-paste text built from the site's own wording.
- Keeps the body to 200 to 350 Chinese characters, plain text, with the only link in the signature and an unsubscribe line.
- Applies a banned word list: "free", the polite "you", em dashes, "guaranteed", "limited time" and the like.
AI search results had one more trap. Asking the same question twice, the number of citations of the recipient's site went from 1 to 0. If every regeneration asked again, a sentence like "AI search cites your site" could stop being true a minute later. So the AI search sentence is assembled by code from the saved result, the model cannot change the numbers, and regenerating after a manual edit reuses the same saved result.
To make sure these checks actually block things, we ran counter-examples: inserting "free", the polite "you", an em dash, an external URL, a fake quote, deleting the "why it was written this way" line, and wrong personalization. Each of the seven triggered its check and failed.
Gate 4: A reviewer playing the owner scores it; 7 or below gets rewritten
The first three gates are about whether it's correct. This one is about whether the recipient would want to reply.
We open a fresh reviewer, ask it to play the owner of that business, and score the whole email from 1 to 10. Ten means they would definitely reply and want to use it, 7 means fine but probably no reply, and 5 or below means straight to the trash. Every round uses a new reviewer, gives it the previous score and deductions as a baseline, and requires it to check the website snapshots, posts and saved AI search results itself instead of only reading the email.
The rule: 7 or below gets rewritten, at most two rounds. Results:
| First version | Rewrite round 1 | Rewrite round 2 | |
|---|---|---|---|
| Average of five | 5.7 | 6.6 | 7.0 |
| Best | 7 | 7.5 | 7.5 |
All five beat the first version, by up to 2 points, but none reached 8.
What happened after the two rounds matters more. We ran one more fact check and still found errors: a draft promoted an event whose page was password-protected, with only the title public; an old post had the wrong month; and a line saying "you often use A, B, C options to get replies" was true of only 1 in more than a dozen posts. Earlier, the first round had promoted a course starting two days after the email would go out, with an early-bird deadline long past. That led to a rule: never promote anything starting within 7 days or whose early-bird has ended.
The scoring reviewer can tell whether it would reply. It can't always tell whether a sentence is true. So scoring and fact checking have to be separate steps. We fixed all of these errors, but we didn't score again afterward, so we don't know the final drafts' scores.
What's still missing
From the last round of reviewers, as they said it:
- No real person in the signature. It's signed "the team", and reviewers on three of the emails said "I don't know who wrote this."
- Nothing to offer brands that already post diligently. One recipient has someone posting at a fixed time every day, so "schedule a week at once" means little; we need to talk about what they aren't covering.
- Three findings is forced. In one email the recipient would only care about one; the other two were filler.
- The ten-minute fix has no caveat. Some site backends can't edit the head, so we need a one-line message they can forward to their web vendor.
- AI search used only one engine, while the email reads as if we asked several.
One more thing to admit: a reviewer playing an owner is not a real owner. The score is only a proxy.
Checklist
- Build a brand brief for each recipient: six fields, a source on every line, and a stated reason for anything you couldn't get.
- Open with one specific thing they did in the last 30 days, with the date and a short verbatim quote.
- Compute voice from their posts with code and give the numbers to the model.
- Reject any draft sharing 18 or more consecutive characters with a published post.
- Attach a source snippet to every fact and re-check it with code; reject numbers the source doesn't have.
- Save changing external results (like AI search) once and reuse the saved copy.
- Use counter-examples to confirm every check actually blocks.
- Have a reviewer play the recipient and score; rewrite anything at 7 or below, at most two rounds; then run a separate fact check.
FAQ
How do you make AI-written drafts sound like the recipient wrote them?
Pull their recent posts and compute their voice with code: average length, lines per post, emoji, hashtags, which second-person form they use, how many questions. The model writes to those numbers instead of guessing a style. Then add one more check: if a draft shares 18 or more consecutive characters with a post they already published, send it back, because copying their own post from yesterday is worse than not sounding like them.
How do you keep AI outreach from getting facts about the recipient wrong?
Every sentence that states a fact about them needs a source and a verbatim snippet, and code re-checks it against the saved pages. Any number in a draft that is not in the source sends it back, including numbers written as Chinese characters. Even so, a fact check after two scoring rounds still caught a password-protected event page treated as public, a wrong month and an over-reaching inference. The last gate has to be a person or a separate reviewer checking against the raw material.
Is this outreach flow sending emails already?
No. Not one email has been sent, and there is no sending code in the program. This post describes a process design. The five samples were only scored internally; the best reached 7.5 and none reached 8. We don't think they are good enough to send yet.