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.

  • Writing & Docs
  • $ghostwriting-voice
  • 175 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- ghostwriting-voice

Installs to ~/.agents/skills/ghostwriting-voice/ for every project on this machine.

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

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.

~/.agents/skills/ghostwriting-voice/SKILL.md

--- name: ghostwriting-voice description: Writes posts, essays, newsletters, talks and emails in a specific person's voice by building a voice profile from their own samples - sentence… license: MIT ---

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

  • 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

NeedGuidance
Amount1,500+ words minimum; 5,000+ for a stable profile. Several pieces beat one long one
TypeSame genre and channel as the target (LinkedIn posts for LinkedIn, essays for essays)
AuthenticityWritten by the author themselves, not previously edited by a team or ghostwriter
RecencyLast 1-2 years; voices drift
Unpolished inputVoice 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

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

  1. Read the samples and record what numbers cannot capture:
DimensionQuestions to answer from the samples
StanceOpinionated or balanced? Hedges (“I think”) or asserts? Contrarian?
OpeningsStarts with a story, a claim, a number, a question, a one-line hook?
StructureLists or prose? Headings? Short sections? How do pieces end (lesson, question, CTA, nothing)?
EvidencePersonal anecdote, data, named examples, analogies? From which domains (sport, cooking, engineering)?
DictionPlain or technical? Jargon they use vs avoid? Profanity? British or American spelling?
HumourDry, self-deprecating, none? Where does it appear?
Signature movesRecurring phrases, rhetorical patterns (“Here’s the thing”), formatting quirks
NeverWords, topics, formats and tones they never use
  1. 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

  1. 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.
  2. Write the draft following the profile’s moves and mechanics. Use the author’s terms for things, not synonyms.
  3. 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.
  4. 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 metricFix
sentence_len_mean higherSplit compound sentences; cut clauses that start with “which” or “while”
contractions lowerRestore natural contractions (it is to it’s)
ly_adverbs higherDelete or replace with a concrete verb or detail
semicolons or colons higherUse full stops, or the author’s usual connector
flesch_reading_ease lowerShorter words and sentences; replace abstractions with examples
i_per_1k much lowerThe 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 --compare or each OFF metric 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.yamlgenerated from this page

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.py173 lines, 7.8 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem