| name | email-voice-builder |
| description | Build a personal email-voice profile from your own Gmail SENT mail, then install it as a personal voice skill. Walks you through exporting your sent mail privately, isolating your own words from quoted replies and signatures, measuring an objective style fingerprint, checking whether your voice drifted over the years, and folding the result into a reusable voice SKILL.md. Raw mail never leaves your machine; only scrubbed exemplars are ever shared. |
| triggers | ["build my email voice","email voice builder","learn my writing voice","model my email style","personal voice from gmail","clone my writing style","voice profile from sent mail"] |
Email Voice Builder
Teach any assistant to write email in your voice by mining your own Gmail
SENT mail. The output is a personal voice skill: a short file that pairs a few
measured facts about how you write with 4-5 real (scrubbed) email exemplars and
your own hard rules. That combination is what makes generated drafts sound like
you instead of like a generic assistant.
This skill is tool-agnostic. It describes a method and the exact gotchas that
break it, with small code fragments where the precise logic matters. You can run
each step by hand, in a notebook, or with scripts you write - nothing here
depends on a specific person's accounts, tokens, or file paths.
Why sent mail, and why exemplars
Your sent mail is the only corpus that is definitionally in your voice - you
wrote every word. The strongest voice signals are the habits you do not
consciously control: sentence-length variance, function-word rates,
punctuation preferences, greeting and sign-off choices. Topic and vocabulary
shift with the subject; these habits do not.
For reproducing a voice, few-shot exemplars are the workhorse. Research on
style imitation puts zero-shot instructions at a few percent style-match and
4-5 well-chosen exemplars near-indistinguishable, with fidelity plateauing
after about 4-5 examples. So you measure across many messages to learn the
profile, but you condition generation on only a handful of real examples plus a
short list of hard rules the model would never infer on its own.
Configuration (nothing is hardcoded)
Everything user-specific is a variable you set once. Suggested names:
| Variable | What it is | Example |
|---|
VOICE_CORPUS_DIR | Local-only folder for raw mail and intermediate data. Must be OUTSIDE any cloud-synced folder (see Step 1). | ~/voice-corpus-local |
VOICE_IDENTITIES | Every address that counts as "written by you" (primary + any send-as aliases). | you@example.com, you@work.example |
PERSONAL_DOMAINS | Consumer-webmail domains to treat as personal recipients. | gmail.com, yahoo.com, icloud.com, outlook.com, hotmail.com |
VOICE_SKILL_PATH | Where the finished personal voice skill will live. | ~/.your-assistant/skills/my-voice/SKILL.md |
Set these as environment variables, a small config file, or CLI flags - whatever
your setup prefers. Do not paste real addresses or paths into any file you
plan to share.
The pipeline (8 steps + a qualitative close-read)
1. Privacy setup local-only corpus folder, never cloud-synced
2. Export SENT Google Takeout (primary) or Gmail API (alternative)
3. Isolate YOUR text strip quoted replies + signatures <-- highest-risk step
4. Fingerprint measure objective style facts, per genre
4.5 Close-read read a stratified sample in batches; log recurring MOVES, not just mechanics
5. Drift check did your voice shift over the years? you choose weighting
6. Build profile measured facts + close-read moves + scrubbed exemplars per genre
7. Install fold facts + exemplars + hard rules into a voice SKILL.md
8. Auto-apply wire a hook so the skill applies on its own, unprompted
Step 1 - Privacy first (do this before exporting anything)
Your sent mail contains confidential, financial, and personal content. It must
never touch a cloud-synced folder.
-
Create VOICE_CORPUS_DIR outside any Dropbox, Google Drive, OneDrive,
iCloud, or Syncthing folder. A plain home-directory path like
~/voice-corpus-local is usually outside sync - but verify your own sync
and backup clients do not include your home directory before you trust it.
Whole-home setups (Backblaze, rclone, a Dropbox/Drive root pointed at
$HOME, or "stream my whole home" modes) will happily upload a
home-directory path despite these instructions. The only dependable
protection is a location your sync/backup client is not watching; confirm
that, then lock the folder down (chmod 700).
-
A .nobackup / exclusion sentinel is not enough. Those only stop some
backup jobs. Live-sync clients (Dropbox, Google Drive for Desktop, etc.)
will still upload a folder that sits inside their synced tree regardless of
any sentinel file. The only reliable protection is to keep the raw corpus in a
directory the sync client never watches. Verify: on macOS, check for
com.dropbox.* / com.apple.*cloud* extended attributes
(xattr <dir>); if the folder is inside a synced root, move it out.
-
Only two kinds of artifact ever leave this folder: aggregate numbers
(the fingerprint) and individually scrubbed exemplars (Step 6). Raw mail,
full cleaned bodies, and per-message metadata stay local.
-
No external model sees raw mail. Parsing, cleaning, de-duplication, and
metrics are all deterministic local code. If you later use a model to help
classify ambiguous messages or draft the profile, feed it only text that is
already cleaned to author-only and scrubbed of identifying specifics.
-
Tear the corpus down deliberately when you're done. The raw corpus (and
any OAuth token, which is a live standing credential to your mailbox) is
confidential; don't leave it lying around. Guard the delete so an unset or
empty variable can't turn it into a silent no-op that fools you into thinking
a corpus is gone when it isn't:
rm -rf "${VOICE_CORPUS_DIR:?corpus dir not set}"
Note that on an SSD rm does not securely erase the underlying blocks -
rely on full-disk encryption (FileVault / LUKS / BitLocker) for real at-rest
protection, not on deletion. If you used the Gmail API path, also revoke
the OAuth token and delete the token file afterward.
Step 2 - Export your SENT mail
Primary path: Google Takeout. Free, first-party, one export.
- Go to Google Takeout, deselect everything, select Mail.
- Choose Select labels and pick Sent only.
- Export. You get an
.mbox file. A decade of one person's sent mail is small
(well under any Takeout size cap), so a single export usually suffices.
- Move the
.mbox into VOICE_CORPUS_DIR.
Parse it with the Python standard library - no third-party mail parser needed:
import mailbox
box = mailbox.mbox("sent.mbox")
for key, msg in box.iteritems():
...
Alternative path: Gmail API. Useful if you want stratified sampling (N
messages per year), incremental fetching, or you would rather not download the
whole archive. Keep it strictly read-only - only users.messages.list and
users.messages.get. Do not hardcode anyone's OAuth client, token file, or
credentials; use your own OAuth setup and reference it via a variable. A few
practical notes that save pain:
resultSizeEstimate is unreliable (it returns a flat placeholder). To count
real messages, page through messages.list and count IDs.
- Fetch bodies with
format=raw and write each into an mbox with mailbox, so
the rest of the pipeline is identical to the Takeout path.
- Add jittered backoff on
429 and 5xx responses, and on 403 with
rateLimitExceeded / userRateLimitExceeded. Sleep briefly between fetches.
- Make the fetch resumable: append mode that skips message IDs already written,
so an interrupted run continues instead of duplicating.
Scope the corpus while exporting or filtering. You usually want your
business/formal register, not casual personal notes. Filter by recipient
domain: keep mail sent to work/company/vendor/recruiter domains, drop mail whose
only recipients are in PERSONAL_DOMAINS - excluding on both To and Cc so a
business message that CC's a personal address is still dropped. Casual mail that
slips through gets caught later at genre classification (Step 4).
Step 3 - Isolate YOUR words (the highest-risk step)
If you measure the raw body you are measuring your correspondents' voices too -
their quoted replies and their signatures bleed in. This single step corrupts
more voice models than anything else, so treat it carefully and eyeball the
output before trusting it.
You are stripping two things: quoted replies and signatures.
3a. Get a plain-text body
Prefer the text/plain part. If a message is HTML-only, remove the HTML quote
containers before stripping tags, or the quoted reply survives as plain text:
HTML_QUOTE = [
r"<blockquote\b.*?</blockquote>",
r'<div[^>]*class="[^"]*gmail_quote[^"]*".*',
r'<div[^>]*class="[^"]*moz-cite-prefix[^"]*".*',
]
Drop those (dot-matches-newline, case-insensitive), convert <br>/</p> to
newlines, then strip remaining tags.
3b. Cut the quoted reply
Cut everything from the first quote marker onward. The markers:
QUOTE_CUTS = [
r"^On .{0,200}?\bwrote:",
r"^-{2,}\s*Original Message\s*-{2,}",
r"^_{5,}\s*$",
r"^From:\s.+\nSent:\s",
r"^El .{0,200}?escribió:",
]
Two things make the first pattern work and are easy to get wrong:
re.DOTALL (re.S) so .*? crosses the newline the wrapped header
introduces. Without it the "On ..." line and the "... wrote:" line never match
together.
- A bounded quantifier (
.{0,200}?) rather than .*?, so a stray "On" and
a distant "wrote:" elsewhere in your own text cannot swallow real content.
Then remove any residual quoted tail - a run of two or more consecutive
>-prefixed lines marks where a plain-text quote begins:
QUOTE_BLOCK = r"(?:^[ \t]*>.*\n){2,}"
3c. Cut the signature
Two tiers, because signature markers differ in how much you can trust them:
- Anywhere-safe markers - cut at the first occurrence regardless of
position: the
-- sig delimiter (dash-dash-space on its own line),
Sent from my ..., Get Outlook for ....
- Tail-only markers - your own name, phone number, or company line. These
are only trustworthy in the last ~40% of the message, because they can
legitimately appear mid-body ("call me at ..." or naming yourself). Only cut
when the match falls past a length threshold (e.g.
match.start() >= 0.6 * len(text)). Configure these from VOICE_IDENTITIES and your own contact
details - never hardcode a specific person's name or number.
3d. quotequail with an over-strip fallback
A regex baseline catches the common cases; the quotequail
library catches quote structures the regex misses. But quotequail can
occasionally over-strip and delete your actual message. So run both and
choose defensively:
def clean_body(raw_text):
reg = regex_clean(raw_text)
if quotequail is None:
return reg
try:
segs = quotequail.quote(raw_text, limit=None)
visible = "".join(s for keep, s in segs if keep)
except Exception:
return reg
q = regex_clean(visible) if visible.strip() else ""
return q if q else reg
For signature stripping specifically, talon
(or a Python-3 fork) is stronger than regex; email-reply-parser is a lighter
plain-text alternative. Any of these is optional - the regex tiers above are
enough to start.
3e. Verify cleaning before trusting the numbers
Compute a leakage rate: the fraction of cleaned messages that still contain
tell-tale residue - a leading > line, a dangling wrote: at end of line, or
your own signature phone/name still present. Print a few cleaned samples and
read them. Iterate on Step 3 until leakage is low (aim for a couple of percent);
a fresh pipeline commonly starts around 30% leakage and drops below 10% once the
wrapped-header gotcha is fixed. Do not proceed to measurement on a corpus you
have not eyeballed - every downstream number inherits this step's mistakes.
Also drop near-empty messages (e.g. under ~15 words after cleaning) - one-liners
add noise, not signal - and de-duplicate near-identical templates before
counting, or they inflate "formulaic" signals.
Step 4 - Measure a fingerprint
Compute objective facts on the cleaned, author-only text. Aggregate across many
messages (a single email is too short to be meaningful; ~1,000+ words stabilizes
distributions). Per-message metrics worth capturing:
| Signal | How to measure it | Watch out |
|---|
| Sentence length | mean, std-dev, and max words/sentence | variance matters as much as the mean - it captures rhythm |
| Em-dash use | count the real em-dash character — only | NOT --. See the gotcha below. |
| I : we ratio | rate per 100 words of first-person singular vs plural | reveals solo vs team framing |
| Contractions | rate of don't, I'm, we'll, etc. per 100 words | formality signal |
| Greeting | which opener, from the first non-blank line (Hi, Hey, Hello, Dear, bare name) | keep the distribution, not just presence |
| Sign-off | which closer, from the last few lines (Thanks, Best, Cheers, bare name) | keep the distribution |
| Punctuation | comma, semicolon, exclamation, question, ellipsis rates | highly personal, unconscious |
| Structure | words/message, sentences/paragraph, one-sentence-paragraph tendency, bullet use | |
The em-dash gotcha. Count the true em-dash character — (U+2014) and the
double hyphen -- as separate metrics. They are not the same: -- shows up
constantly in signatures, URLs, and separator lines, so conflating them
massively overstates "em-dash usage". Many writers who appear em-dash-heavy
actually use the real — almost never once you separate the two.
"em_dash": text.count("—"),
"double_hyphen": text.count("--"),
Measure per genre, not just overall. Register shifts hard between genres, so
averaging them together blurs the profile. Classify each message first, then
fingerprint each stratum. A simple keyword+recipient rule-based classifier is
enough to start; useful business buckets:
- client/project update - status, shipped, deployed, next step, progress
- proposal / pitch - proposal, quote, estimate, scope, fixed fee, milestone
- negotiation / terms - rate, retainer, invoice, contract, payment, deposit
- vendor / recruiter / partner - role, candidate, demo, trial, onboard
Mail whose recipients are all personal domains falls to a personal_casual
bucket you exclude from the formal profile. Genuinely ambiguous business mail can
sit in a business_unclassified bucket; if that bucket is large, a model pass
over the cleaned text can resolve it into richer per-genre facts later.
Step 4.5 - Qualitative close-read (moves, not mechanics)
The fingerprint measures mechanics - length, punctuation, greeting mix, I-vs-we
rate. Those are necessary but not sufficient. What actually makes a draft sound
like you is the recurring rhetorical moves and templated structures: how you
decline, how you correct yourself, how you open, how you signpost, how you close.
A statistical fingerprint captures none of that - it cannot see that your declines
follow a fixed three-part shape, or that you screen applicants with an inline
numbered triad, or that you send a dedicated repair note when you got something
wrong. Those patterns only surface when a human (or a model) reads the mail.
So after the fingerprint, read a large, stratified sample in batches and log
the moves per genre.
Read to saturation. Do not guess a number up front - read until a batch stops
surfacing new patterns.
- Size batches at ~50 emails each.
- Stratify the sample across years and genres so a single high-volume period
or register doesn't dominate what you read.
- After each batch, ask: did this batch reveal a move I hadn't already logged?
Keep going while the answer is yes.
- In practice, a decade of one person's mail saturated around 400-450 emails /
~8 batches, with the last one or two batches mostly confirming rather than
adding. Your own corpus may saturate sooner or later; the stopping rule, not the
number, is what matters.
Let the close-read define the genre buckets. The keyword+recipient classifier
from Step 4 is a starting point, but it misses emergent genres - a keyword rule
dumps a large fraction into an "unclassified business" bucket and is blind to
registers like the warm decline, the self-correction/repair note, or the
line-by-line verification/assurance email. Use what you see in the close-read to
define the real genre taxonomy, then fold the richer buckets back into the
per-genre fingerprint. Several of the highest-value registers are invisible to a
keyword classifier and only appear when you read.
Emit a structured findings artifact per batch - not the raw emails. Each batch
must produce a short findings document, not a pile of pasted messages. For every
recurring move, capture: the pattern (a one-line name and description), a
representative quote (which you will scrub before it travels), and the target
skill section it should inform. The final consolidation runs over these
per-batch findings, not over the raw corpus a second time. (A pipeline that stores
"batch reports" as raw chunked mail has not actually performed the distillation -
it has just re-chunked the corpus.)
Why this step earns its place. On a real decade-long corpus, the fingerprint
captured length, punctuation, and greeting mix - and none of the highest-value
findings: the decline template, the screening triad, the correction email, the
counted-signpost opener. Those came only from reading hundreds of individual
emails. Skipping the close-read leaves you with a voice that has the right
mechanics and none of the moves.
Step 5 - Drift diagnostic (did your voice shift?)
A decade is long enough that your voice may have evolved. Do not assume it did
or did not - measure it, then decide.
- Bucket the cleaned messages by year and compute the fingerprint per year.
- Compare an early band (e.g. first ~4 years) against a recent band
(e.g. last ~4 years) across the drift-sensitive metrics: mean sentence length,
I:we rate, em-dash rate, exclamation rate, contraction rate, greeting/sign-off
mix, words per message. Print the shift (recent minus early) for each.
- Average years evenly, don't pool messages. High-volume years would
otherwise dominate. Compute each year's fingerprint, then average the yearly
fingerprints - so a year with 3,000 messages and a year with 300 count the
same when you look at "your voice over time".
Then you choose the weighting - this is a decision, not an automatic step:
- Even weighting - treat the whole decade equally. Good when the drift
report shows little change, or when you want your "timeless" voice.
- Recent weighting - window to just the last few years. Good when the report
shows real drift and you want the profile to reflect who you write like now.
Look at the drift table and pick deliberately. A common outcome: an older
register (say, a we-heavy "operator" voice from years running a team) versus a
current register (a leaner first-person voice) - in which case recent-weighting
better matches how you write today. Freeze whichever fingerprint you chose as the
one you'll build the profile from.
Step 6 - Build the profile
The profile has two halves plus a precedence rule.
a. Measured facts + moves. Distill the chosen fingerprint into a short block of
plain statements: typical sentence length and range, greeting/sign-off habits with
their rough frequencies, em-dash reality, I:we tendency, contraction level,
punctuation quirks - per genre where it differs. Keep it tight; a model needs the
shape, not a spreadsheet. Then fold in the recurring moves from the Step 4.5
close-read - the decline template, the signposting opener, the screening triad, the
correction note, and any other templated structures you logged. The mechanics tell
the model how your sentences look; the moves tell it what your emails do.
b. Exemplar bank. Pick 4-5 real emails per genre. Selection rules that
matter:
- Choose exemplars to span the range within the register (a short crisp one,
a longer detailed one, different opening moves) - not by topic similarity.
Topic-clustered exemplars narrow the range and reduce fidelity.
- Scrub every exemplar before it leaves
VOICE_CORPUS_DIR: remove client and
company names, real people's names, dollar figures, phone numbers, addresses,
project code-names, and any confidential specifics. Replace with neutral
placeholders ([client], [project], [amount]). The structure and voice
survive scrubbing; the sensitive content does not travel. This is the only form
in which real mail content ever leaves the local folder.
- Stop at ~4-5 per genre. Fidelity plateaus; more exemplars just bloat the skill.
- Genre coverage can matter more than per-genre depth. When the close-read
surfaced many distinct registers, prefer breadth: 1-2 exemplars across 10 real
genres beats 5 across 3. A writer with a rich set of registers (say, a warm
decline, a screening triad, a verification note, a retainer negotiation) is
better served by one solid specimen of each than by five near-duplicates of the
register you happened to write most often.
c. The precedence rule (critical). Your current hard rules and preferences
override any historical habit the corpus shows. The corpus is a description of
how you have written, not a mandate for how you must write. If you have
decided you never write "we", never use an em-dash, or always use a single
hyphen, those rules win even if older mail did the opposite. State this
explicitly in the profile so the model never "resurrects" an abandoned habit it
found in the data. Where an induced observation and a hard rule disagree, the
hard rule wins; where an induced observation and a measured number disagree, the
number wins.
Step 7 - Install as a personal voice skill
Fold the three parts into a single personal voice SKILL.md your assistant
loads when writing on your behalf. Use templates/personal-voice-SKILL.template.md
as the fill-in-the-blanks starting point. The finished file contains:
- Hard rules (highest precedence) - your non-negotiable prose constraints.
- Measured facts - the fingerprint block from Step 6a.
- Exemplar bank - the scrubbed 4-5-per-genre examples from Step 6b.
- Precedence note - hard rules > measured facts > corpus habits.
Install it at VOICE_SKILL_PATH. If that file already exists (you're refining an
existing voice skill rather than creating one), version it first - commit or copy
the current version so a bad update is one revert away - and note the pre-change
state so you can roll back cleanly.
Final gate: your own eyeballs
Automated evaluation helps but does not replace you reading the output. If you
can, run a blind win-rate check: have a different model than the one that
generated the draft pick which of a real email and a generated one is real,
order randomized; at/near 50% ("can't beat chance") the imitation is
indistinguishable. But the real acceptance test is you generating a few drafts
with the new skill and confirming they read like you. A passing metric is not the
same as you being satisfied - you are the final gate.
Step 8 - Make it apply automatically (the auto-apply harness)
Installing the skill is not the same as using it. By default the assistant only
loads a voice skill when you ask ("in my voice"). The point of building a voice is
that you shouldn't have to ask every time. Two layers get you there, and you want
both:
a. A standing instruction (always-on default). Add one line to your
assistant's persistent instructions (for Claude Code, ~/.claude/CLAUDE.md or a
project CLAUDE.md):
When drafting anything sent on my behalf (email, message, post, proposal, cover
letter), apply my personal-voice skill by default.
This is the durable floor. It is in context on every turn - including
subagents, which inherit CLAUDE.md. Use your own skill name.
b. A per-prompt hook (salience). A standing instruction can get buried in a
long conversation. A UserPromptSubmit hook re-asserts it at the moment you
submit a writing prompt - right where the model decides what to do. This repo
ships that hook, generalized and free of any personal data, at
hooks/voice-reminder.sh:
- It reads the prompt, and on a writing-shaped request (email, reply,
message, post, proposal, draft/rewrite/compose, cover letter, ...) injects a
short instruction to load and follow your voice skill before drafting.
- It is communication-scoped: it deliberately does not fire on "write a
function", "write tests", or "compose a query", so it never nags on code.
- The skill name is a variable (
VOICE_SKILL_NAME, default personal-voice) -
never hardcoded.
- Opt out for a single turn with
#novoice.
Full install steps (registering it in settings.json, setting the skill name,
and how to test that it fires on writing and stays silent on code) are in
hooks/README.md.
The honest limit. A hook cannot referee "warmth" or judge whether the draft
is actually in your voice - it only makes the model reach for the skill. The
skill (measured facts + exemplars + hard rules from Steps 6-7) is what
defines the voice. If a draft comes out wrong, fix the skill, not the hook.
Note the division of labor between the two layers: CLAUDE.md is the always-on
default that even subagents inherit; the hook adds per-prompt salience for the
main session (subagents don't fire UserPromptSubmit). Neither fully replaces
the other.
One-page checklist