Ghostwriting Voice skill for Codex
Sound like the author, from their own samples. Codex builds a voice profile from the author's own writing, combining a bundled stylometry script (sentence rhythm, contractions, pronouns, punctuation, readability, signature phrases) with notes on stance, openings and things they never say. Drafts come from the author's ideas, are checked against the profile with flagged deviations, and mark every unverified fact or missing personal story for the author.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ghostwriting-voice Installs to ~/.agents/skills/ghostwriting-voice/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ghostwriting-voice The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ghostwriting-voice Installs to .agents/skills/ghostwriting-voice/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ghostwriting-voice.zipunzip -o ghostwriting-voice.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ghostwriting-voice PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ghostwriting-voice, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes posts, essays, newsletters, talks and emails in a specific person's voice by building a voice profile from their own samples - sentence rhythm, vocabulary, pronoun use, punctuation habits, recurring moves and things they never say - measured with a bundled stylometry script, then drafting from their ideas and checking the draft against the profile. Use when asked to write as or for someone (a founder, executive, client or the user), match the voice of existing posts, turn rough notes or a voice memo transcript into their writing, or make a draft sound less generic.
Use it when
- You write posts, essays or emails on behalf of a founder, executive or client
- You want AI-assisted drafts that do not sound like AI
- You have samples of someone's writing and notes for a new piece
Use something else when
- You need one shared voice for a whole company's marketing: use brand-voice
- You are writing a work email in your own voice: use professional-email
How to check the result
Run scripts/voice_profile.py with --compare on the draft and resolve every OFF metric, then confirm every anecdote and number traces to the author's own material.
The mistake it prevents
Inventing anecdotes or opinions to make a draft lively and attributing them to the author.
A ghostwritten draft succeeds when the named author reads it and says “I would have written that”, their regular readers notice nothing, and every idea, story and opinion in it actually belongs to the author. Voice is built from evidence (their real writing), not adjectives like “witty and authentic”.
Consent and honesty
- The person whose voice is used must know and approve: either the user is that person, or the user writes on their behalf with their agreement (staff writer, agency, chief of staff). Ask if it is unclear.
- Do not write in a real person’s voice to deceive others about who said something (fake quotes, reviews, testimonials, messages pretending to be someone who has not agreed), and do not produce work someone will submit where personal authorship is required (graded coursework, signed statements, some grant and journal contexts).
- Never invent experiences, anecdotes, customers, numbers or opinions and attribute them
to the author. Use placeholders like
[STORY: a time a launch slipped?]for the author to fill. - Publishing or sending under the author’s name happens only after they approve the final text.
Company-wide brand voice (one voice for many writers) belongs to $brand-voice.
Collect samples
| Need | Guidance |
|---|---|
| Amount | 1,500+ words minimum; 5,000+ for a stable profile. Several pieces beat one long one |
| Type | Same genre and channel as the target (LinkedIn posts for LinkedIn, essays for essays) |
| Authenticity | Written by the author themselves, not previously edited by a team or ghostwriter |
| Recency | Last 1-2 years; voices drift |
| Unpolished input | Voice memos, Slack messages, talk transcripts: reveal natural phrasing and opinions |
Save samples as plain text or Markdown in one folder (for example voice/samples/).
Ask what the author dislikes in their own writing or in others’ (phrases, formats); that
list is often the most useful input.
Build the profile
- Measure the mechanics with the bundled script:
python3 scripts/voice_profile.py voice/samples/*.md --json voice/profile.json
It reports sentence length (mean, median, p90, share of very short sentences), paragraph length, bullet use, contractions, I / we / you rates, questions, exclamations, dashes, semicolons, colons, parentheses, -ly adverbs, emoji, word length, Flesch reading ease, favourite sentence openers, repeated phrases and top content words, with the range across samples.
- Read the samples and record what numbers cannot capture:
| Dimension | Questions to answer from the samples |
|---|---|
| Stance | Opinionated or balanced? Hedges (“I think”) or asserts? Contrarian? |
| Openings | Starts with a story, a claim, a number, a question, a one-line hook? |
| Structure | Lists or prose? Headings? Short sections? How do pieces end (lesson, question, CTA, nothing)? |
| Evidence | Personal anecdote, data, named examples, analogies? From which domains (sport, cooking, engineering)? |
| Diction | Plain or technical? Jargon they use vs avoid? Profanity? British or American spelling? |
| Humour | Dry, self-deprecating, none? Where does it appear? |
| Signature moves | Recurring phrases, rhetorical patterns (“Here’s the thing”), formatting quirks |
| Never | Words, topics, formats and tones they never use |
- Write the profile file the drafts will follow:
# Voice profile: <Name> (from 9 LinkedIn posts + 2 essays, 6,800 words, 2025-2026)
## In one line
Direct operator who teaches through one specific failure per post; short sentences, no jargon.
## Mechanics (from voice_profile.py)
- Sentences: median 9 words, 40% under 7 words; one long sentence per paragraph at most
- Paragraphs: 1-3 sentences; frequent one-line paragraphs
- Contractions always; "I" heavy, "we" for the team; questions about 1 per 150 words
- Dashes yes, semicolons never, no emoji, no exclamation marks
## Moves
- Opens with a concrete moment (time, place, number), never with a definition
- Middle: what went wrong, what changed, one number
- Ends with a single-sentence lesson; no "What do you think?" CTA
## Vocabulary
Uses: ship, customers, boring, fix. Avoids: leverage, synergy, journey, game-changer, "excited to announce".
## Never
Hashtag blocks, listicles over 5 items, motivational quotes, criticizing named competitors.
## Sample paragraph (verbatim, for calibration)
> ...
Keep the verbatim calibration paragraphs short (2-3) and from the author’s own samples.
Draft
- Start from the author’s material: notes, outline, transcript of them talking, bullet points of their opinion. If the user gives only a topic, ask for the author’s view and one real example before drafting; generic content in a good imitation of the voice is still generic.
- Write the draft following the profile’s moves and mechanics. Use the author’s terms for things, not synonyms.
- Put
[CHECK]markers on any fact, number or name you could not verify from the material, and[STORY: ...]placeholders where the piece needs a personal example. - Write two openings when the opening move is uncertain, and let the author choose.
From a voice memo transcript: keep their phrasing wherever it works in writing; remove fillers, false starts and repetition; reorder for the written form; do not “upgrade” their vocabulary.
Check the draft against the profile
python3 scripts/voice_profile.py voice/samples/*.md --compare drafts/post.md
Lines marked OFF show metrics outside the samples’ range (exit code 1 when any are).
Typical fixes:
| OFF metric | Fix |
|---|---|
| sentence_len_mean higher | Split compound sentences; cut clauses that start with “which” or “while” |
| contractions lower | Restore natural contractions (it is to it’s) |
| ly_adverbs higher | Delete or replace with a concrete verb or detail |
| semicolons or colons higher | Use full stops, or the author’s usual connector |
| flesch_reading_ease lower | Shorter words and sentences; replace abstractions with examples |
| i_per_1k much lower | The draft lectures instead of telling; put the author back in the story |
Metrics are a smoke alarm, not the target. A draft can match every number and still sound wrong; read it aloud against a sample paragraph. Also scan for generic filler that no individual voice contains:
rg -n -i "in today's|fast-paced|landscape|game.?changer|unlock|elevate|delve|journey|excited to (share|announce)|let's dive" drafts/post.md
Hand-off
**Draft:** drafts/2026-11-post-onboarding.md (412 words, LinkedIn)
**Profile check:** 0 metrics off range; 2 signature moves used (concrete opening, one-line lesson)
**For you to fill or confirm:**
- [STORY] paragraph 3: a customer moment where onboarding confused someone
- [CHECK] "support tickets dropped 48%" - from your notes, confirm the number and period
**Alternatives:** two opening lines at the end of the file
Then iterate on the author’s edits: their changes to the draft are the best new samples.
Add approved, author-edited pieces to voice/samples/ and rerun the profile periodically.
Gotchas
- Imitating surface tics (one catchphrase in every post) produces parody. Use signature moves at the frequency they appear in the samples.
- Samples that were already ghostwritten or heavily edited teach the wrong voice. Ask.
- Mixing genres: someone’s conference talk voice is not their LinkedIn voice.
- Smoothing out deliberate roughness (fragments, informal grammar) that is part of the voice.
- Adding claims or stories “for flavour”. Every factual and personal claim must come from the author.
Verification
- The draft passes
voice_profile.py --compareor eachOFFmetric is a deliberate choice. - The filler scan returns nothing.
- Every anecdote, number and opinion traces to the author’s material or is marked for them.
- The author (or the user on their behalf) has approved the text before it is published or sent.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Ghostwriting Voice"
short_description: "Sound like the author, from their own samples"
default_prompt: "$ghostwriting-voice Write a LinkedIn post in my voice from these notes; my past posts are in voice/samples" scripts/voice_profile.py
#!/usr/bin/env python3
"""Measure the style of writing samples and compare a draft against them.
Prints a voice profile (sentence and paragraph length, contractions, pronouns,
punctuation habits, readability, favourite sentence openers and phrases) and, with
--compare, flags where a draft falls outside the range seen across the samples.
Standard library only.
Usage:
python3 voice_profile.py SAMPLE [SAMPLE ...]
python3 voice_profile.py samples/*.md --compare draft.md [--json profile.json]
Exit code: 0 ok (or draft within range), 1 draft has metrics out of range, 2 bad input.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import statistics
import sys
from collections import Counter
from pathlib import Path
STOP = set("""a an the and or but if of to in on at by for with from as is are was were be been being
it its this that these those i me my we our you your he she they them their his her not no so do does
did have has had will would can could should may might just than then there here what which who whom
about into over under also very more most some any all each one two up out how when where why""".split())
# 's counts only after pronouns and function words, so possessives ("today's") do not
CONTRACTION = re.compile(
r"\b(?:\w+n['\u2019]t|\w+['\u2019](?:re|ve|ll|d|m)"
r"|(?:it|that|here|there|what|who|where|he|she|let|how)['\u2019]s)\b",
re.I,
)
WORD = re.compile(r"[A-Za-z]+(?:['’][A-Za-z]+)?")
SENT_SPLIT = re.compile(r"(?<=[.!?])[\"')”\]]*\s+(?=[\"'(“\[]?[A-Z0-9])")
EMOJI = re.compile("[\U0001F300-\U0001FAFF☀-➿]")
def clean(text: str) -> str:
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S) # frontmatter
text = re.sub(r"```.*?```", "", text, flags=re.S) # code blocks
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text) # images
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) # links -> text
text = re.sub(r"https?://\S+", "", text)
return text
def syllables(word: str) -> int:
w = word.lower().strip("'")
if len(w) <= 3:
return 1
w = re.sub(r"(?:[^laeiouy]es|ed|[^laeiouy]e)$", "", w)
w = re.sub(r"^y", "", w)
return max(1, len(re.findall(r"[aeiouy]{1,2}", w)))
def profile(text: str) -> dict:
text = clean(text)
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
bullets = sum(1 for line in text.splitlines() if re.match(r"\s*([-*+]|\d+[.)])\s+", line))
prose = " ".join(p for p in paras if not p.lstrip().startswith(("#", "|")))
sentences = [s.strip() for s in SENT_SPLIT.split(prose) if len(WORD.findall(s)) >= 2]
words = WORD.findall(prose)
n = max(1, len(words))
per_k = lambda count: round(1000 * count / n, 2) # noqa: E731
sent_lens = [len(WORD.findall(s)) for s in sentences] or [0]
para_lens = [len(WORD.findall(p)) for p in paras] or [0]
lower = [w.lower() for w in words]
syl = sum(syllables(w) for w in words)
flesch = 206.835 - 1.015 * (n / max(1, len(sentences))) - 84.6 * (syl / n)
openers = Counter(WORD.findall(s)[0].lower() for s in sentences if WORD.findall(s))
bigrams = Counter(
f"{a} {b}" for a, b in zip(lower, lower[1:]) if a not in STOP or b not in STOP
)
content = Counter(w for w in lower if w not in STOP and len(w) > 3)
return {
"words": len(words),
"sentences": len(sentences),
"sentence_len_mean": round(statistics.mean(sent_lens), 1),
"sentence_len_median": statistics.median(sent_lens),
"sentence_len_p90": sorted(sent_lens)[max(0, math.ceil(0.9 * len(sent_lens)) - 1)],
"short_sentences_pct": round(100 * sum(1 for x in sent_lens if x <= 6) / max(1, len(sent_lens)), 1),
"paragraph_len_mean": round(statistics.mean(para_lens), 1),
"bullets_per_1k": per_k(bullets),
"contractions_per_1k": per_k(len(CONTRACTION.findall(prose))),
"i_per_1k": per_k(sum(1 for w in lower if w in ("i", "i'm", "i've", "i'd", "i'll", "me", "my"))),
"we_per_1k": per_k(sum(1 for w in lower if w in ("we", "we're", "we've", "our", "us"))),
"you_per_1k": per_k(sum(1 for w in lower if w in ("you", "you're", "your", "you've", "you'll"))),
"questions_per_1k": per_k(prose.count("?")),
"exclamations_per_1k": per_k(prose.count("!")),
"em_dashes_per_1k": per_k(prose.count("—") + prose.count(" -- ") + prose.count(" - ")),
"semicolons_per_1k": per_k(prose.count(";")),
"colons_per_1k": per_k(prose.count(":")),
"parentheses_per_1k": per_k(prose.count("(")),
"ly_adverbs_per_1k": per_k(sum(1 for w in lower if w.endswith("ly") and len(w) > 4)),
"emoji_per_1k": per_k(len(EMOJI.findall(text))),
"avg_word_len": round(sum(len(w) for w in words) / n, 2),
"flesch_reading_ease": round(flesch, 1),
"top_openers": [w for w, _ in openers.most_common(8)],
"top_phrases": [p for p, c in bigrams.most_common(12) if c > 1],
"top_words": [w for w, _ in content.most_common(15)],
}
NUMERIC_SKIP = {"words", "sentences"}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("samples", nargs="+", type=Path)
ap.add_argument("--compare", type=Path, help="draft to check against the samples")
ap.add_argument("--json", type=Path, help="write the profile as JSON")
ap.add_argument("--tolerance", type=float, default=0.25, help="slack beyond sample range (fraction, default 0.25)")
args = ap.parse_args()
texts = []
for p in args.samples:
if not p.is_file():
print(f"error: {p} not found", file=sys.stderr)
return 2
texts.append(p.read_text(encoding="utf-8", errors="replace"))
per_sample = [profile(t) for t in texts]
combined = profile("\n\n".join(texts))
if combined["words"] < 300:
print(f"warning: only {combined['words']} words of samples; 1,500+ gives a stable profile\n")
print(f"Voice profile from {len(texts)} sample(s), {combined['words']} words\n")
for k, v in combined.items():
if isinstance(v, list):
print(f" {k:24} {', '.join(v)}")
elif k not in NUMERIC_SKIP:
lo = min(s[k] for s in per_sample)
hi = max(s[k] for s in per_sample)
rng = f" (samples {lo}-{hi})" if len(per_sample) > 1 else ""
print(f" {k:24} {v}{rng}")
out = {"combined": combined, "per_sample": per_sample}
status = 0
if args.compare:
if not args.compare.is_file():
print(f"error: {args.compare} not found", file=sys.stderr)
return 2
draft = profile(args.compare.read_text(encoding="utf-8", errors="replace"))
out["draft"] = draft
print(f"\nDraft {args.compare} ({draft['words']} words) vs samples:")
flagged = 0
for k, v in draft.items():
if isinstance(v, list) or k in NUMERIC_SKIP:
continue
vals = [s[k] for s in per_sample] + [combined[k]]
lo, hi = min(vals), max(vals)
span = max(hi - lo, abs(combined[k]) * 0.2, 0.5)
if v < lo - args.tolerance * span or v > hi + args.tolerance * span:
flagged += 1
direction = "higher" if v > hi else "lower"
print(f" OFF {k:24} {v} ({direction} than samples {lo}-{hi})")
else:
print(f" ok {k:24} {v}")
shared = set(draft["top_phrases"]) & set(combined["top_phrases"])
print(f"\n shared signature phrases: {', '.join(sorted(shared)) or 'none'}")
print(f"\n{flagged} metric(s) out of range")
status = 1 if flagged else 0
if args.json:
args.json.write_text(json.dumps(out, indent=2), encoding="utf-8")
print(f"\nJSON written to {args.json}")
return status
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.