Agency Skills
SkillsCollectionsContributorsContribute
Install every skill
Agency Skills

Skills written by the people who run agencies, consultancies and coaching firms. Free and open source, MIT.

LibraryAll skillsCollectionsContributorsInstall every skillFor AI agents (llms.txt)
ContributeHow to contributeContributor guideGitHubReport a problem
GiaCompany BrainBook a build session
Built by the community. Published by Gia.PrivacyTerms
Skills/Deliver and keep clients/Client performance review doc
Customer Success

Client performance review doc

Use this skill when an agency owes a client its periodic outbound performance review (QBR, 30-60-90, "three months in") and wants a commentable doc the client reads before the call. Pulls the numbers by channel and campaign, writes the summary, levers, what has not worked, new plays and the client's asks, adds a data appendix (TAM, who replies, objections), renders clean tables and charts, and runs the comment-and-call loop. Also use for "performance review", "QBR", "results doc", "write up how outbound is going".

Garrett WolfeFounder, OneGTM
Say something like
“three months in”“performance review”“QBR”“results doc”“write up how outbound is going”
instantlyheyreachhubspotslackqbrclient-reportingoutboundperformance-reviewretention
SKILL.mdView on GitHub

Client performance review

One document the client reads alone, comments on, and then we walk through together on a call. Model: the fictional review in references/example-review.md, rendered by the script.

Output: a Google Doc in the client's shared Drive folder, built from a Markdown source that lives in the client repo at docs/reviews/YYYY-MM-DD-<period>.md (so the next review diffs against it).

Step 1: Pull the numbers (the client project notes say where each lives)

Fix the window first (e.g. launch to last Sunday) and state it in the byline. Pull, per channel and per campaign:

MetricSource (typical)Definition to hold to
Accounts scored / qualified / top tierwarehouse fit tablecurrent fit model only, never a retired one
Signals detected / qualified / reachedsignal tablescount people for recurring signals (promotions fire weekly)
People emailed, emails sent, new peopleInstantly analytics + sent logpeople, not leads rows; dedupe across campaigns
LinkedIn reached / accepted / repliedHeyReach stats
Repliesreply-bot / inbox loghuman replies only: no auto-reply, OOO, bounce, own follow-ups
Positive repliesreply classifierinterest of any kind: book, info, referral, "later"
Said "book a call"classifier / manualthe yes, before it becomes a meeting
Meetings bookedcalendar / HubSpot / confirmed listproof of a meeting, not a positive reply
DealsCRMopen and closed, with stage
Objectionsreply classifier, groupedcount accounts, not messages

Then cut interested per 1,000 reached by company size, seniority, industry, product line, mail provider and channel. This one rate makes every segment comparable. Do not quote a rate on fewer than 100 people; show the count instead ("0 of 400").

Save the pulled numbers as docs/reviews/<date>-data.json (or CSV) beside the Markdown, so every figure in the doc traces back to a query.

Step 2: Write the Markdown in this order

Read references/writing-rules.md first, especially the tone section: honest, but written so the client wants to keep going. Every section opens with its takeaway in one sentence.

  1. Summary: one callout (> **Headline.** ...) plus at most 4 bullets: results, the biggest leak, the best channel, the top objection.
  2. The numbers: funnel chart (scored → meetings), a month-by-channel table, then By campaign, grouped by product line with a bold total row per group. Outcomes (meetings by source, deals) go here as a short table.
  3. How we get more meetings: levers ranked by expected impact: lever, what we'll do, expected impact. Say which ones are untested.
  4. Where we have not seen success: segments, campaigns or signals that did not work, with the count and what we take from it. Be candid; this section builds trust.
  5. What we will improve: our own fixes (speed, tracking, routing, copy, tests). Bullets.
  6. New plays: "what we learned" → "what we will run". Each play tied to a number in section 2 or the appendix.
  7. Where we need <client> to push: asks with the reason each matters. Concrete: a seat, a person, a calendar, a sign-off.

--- page break, then Data appendix:

  • A. TAM: by industry and by fit tier: accounts, top tier, reached, interested per 1k.
  • B. Who replies: company size, seniority/persona, industry; hbar charts against the average. List the replying companies by name where it helps.
  • C. Objections: what they said, accounts, how we answer it.
  • Optional: events, signal lanes, full campaign list, "what runs underneath" (one paragraph), definitions.

Keep the body (sections 1 to 7) to about 4 pages. Anything wider or longer goes to the appendix.

Step 3: Render and check

python3 -m venv /tmp/rv && /tmp/rv/bin/pip install -q python-docx matplotlib
/tmp/rv/bin/python references/build_review_doc.py \
  docs/reviews/2026-10-26-q3.md -o /tmp/review.docx --pdf

Open the PDF (or pdftoppm -r 80 -png) and look at every page: no wrapped number cells, no table wider than the page, every chart titled with its takeaway. Syntax for tables, charts, callouts and column widths is in the script's docstring.

Step 4: Put it in Google Docs

Drag the .docx into the client's Drive folder (Drive converts it to a Google Doc; with "Convert uploads" off, open it and use File → Save as Google Docs). Then: title it <Client>: <period> review (draft), share as Commenter with the client, and look at every table in the Google render. Inter sets wider in Google Docs than in the LibreOffice preview, so a header that just fits in the PDF can wrap in Docs; widen that column and re-import.

Re-importing creates a new doc, and comments do not carry over. Freeze the numbers before sharing. After that, edit in Google Docs by hand and copy the change back to the Markdown.

Step 5: The review loop

  1. Send the doc 2-3 business days before the call, in the shared client Slack channel, using references/slack-message.md (link first, 3 findings with numbers, the asks, a comment deadline, a call ask). Draft it for the account owner; never post it yourself.
  2. Before the call, answer every factual comment in-thread; collect the open questions as the agenda.
  3. On the call: 5 min on the summary, then comments, then plays and asks. Leave with an owner and date for each ask.
  4. After: resolve comments, write decisions/<date>-review.md in the client repo (what was agreed, who owns what), and update the plays.

What good looks like

  • Every figure traces back to the saved data file from a query, never memory, and anything estimated is marked as estimated.
  • Replies are human replies only, and a meeting needs proof of a meeting, not a positive reply. Count people, not lead rows.
  • No rate on fewer than 100 people: show the count instead ("0 of 400").
  • "Where we have not seen success" is candid, with the count and what we take from it. That section builds trust.
  • Red flag: sharing the doc before the numbers are frozen. Re-importing creates a new doc and the client's comments do not carry over.

Hard rules

  • Numbers from queries, never memory. Every figure traces to <date>-data.json. Mark anything estimated as estimated.
  • Unverified claims stay out: no customer names, benchmarks or logos the client has not signed off on.
  • Edit the prose for AI patterns before sharing.
  • Draft first to the account owner; the client gets it only after our review.
4 reference files
references/build_review_doc.py

#!/usr/bin/env python3 """Render a performance-review Markdown file into a clean .docx that converts well to Google Docs.

Google Docs' own tables wrap and crowd because nobody sets column widths, padding or borders. This sets all three, right-aligns numbers, and draws charts as images, so the doc reads cleanly and stays commentable once it is uploaded to Drive (Drive converts .docx to a Google Doc).

Input is ordinary Markdown plus three conventions: | pipe tables | first row is the header. A row whose every filled cell is bold is a group/total row (bold, rule above). Numeric columns right-align.

<!-- table: widths=40,12,12,12,12,12 --> optional, on the line before a table: column % widths

chart {json} a chart (see CHART TYPES below); rendered with matplotlib

Takeaway. text a shaded callout (use for the one line a reader must not miss) --- page break

CHART TYPES (json "type"): hbar {"labels":[...], "values":[...], "fmt":"{:.1f}", "highlight":["Video"], "ref": 4.2, "ref_label":"avg"} bar {"labels":[...], "series":{"Emails":[...], ...}} grouped vertical bars line {"labels":[...], "series":{"Reply %":[...]}, "fmt":"{:.1f}%"} funnel {"labels":[...], "values":[...]} stage counts, top to bottom Every chart takes "title" (the takeaway, not the axis name) and optional "note" (source line).

Usage: python3 build_review_doc.py review.md -o review.docx [--pdf] --pdf also writes review.pdf via LibreOffice, so you can eyeball it before uploading. """ import argparse, json, re, subprocess, sys, tempfile from pathlib import Path

try: from docx import Document from docx.enum.section import WD_ORIENT # noqa: F401 from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Inches, Pt, RGBColor except ImportError: sys.exit("pip install python-docx matplotlib (a venv is fine)")

---- house style: one place to change it ----

FONT = "Inter" # a Google Font, so it survives conversion; falls back to Arial INK = RGBColor(0x1F, 0x23, 0x28) MUTED = RGBColor(0x5F, 0x63, 0x68) ACCENT_HEX = "#2F5BEA" GREY_HEX = "#C4C9D0" RULE = "D9DCE1" # hairline between rows HEAD_RULE = "1F2328" # rule under the header GROUP_FILL = "F3F4F6" CALLOUT_FILL = "EEF2FD" PAGE_W_IN = 6.5 # Letter with 1in margins BODY_PT, TABLE_PT, HEAD_PT = 10.5, 9.5, 9

NUM_RE = re.compile(r"^[~<>≈$€£+-−]?\s?[\d,.]+\s?(%|x|k|m|K|M|pts?)?(\s*(.*))?$|^[ - --]$|^0$")

def set_cell_borders(cell, top=None, bottom=None): tcPr = cell._tc.get_or_add_tcPr() borders = OxmlElement("w:tcBorders") for side, spec in (("top", top), ("bottom", bottom), ("left", None), ("right", None)): el = OxmlElement(f"w:{side}") if spec: el.set(qn("w:val"), "single"); el.set(qn("w:sz"), str(spec[0])); el.set(qn("w:color"), spec[1]) else: el.set(qn("w:val"), "nil") borders.append(el) tcPr.append(borders)

def set_cell_fill(cell, hex_fill): shd = OxmlElement("w:shd") shd.set(qn("w:val"), "clear"); shd.set(qn("w:color"), "auto"); shd.set(qn("w:fill"), hex_fill) cell._tc.get_or_add_tcPr().append(shd)

def set_cell_margins(table, top=70, bottom=70, left=90, right=90): # twips (20 = 1pt) tblPr = table._tbl.tblPr mar = OxmlElement("w:tblCellMar") for side, v in (("top", top), ("bottom", bottom), ("left", left), ("right", right)): el = OxmlElement(f"w:{side}"); el.set(qn("w:w"), str(v)); el.set(qn("w:type"), "dxa"); mar.append(el) tblPr.append(mar)

def fix_grid(table, widths): """Fixed layout + explicit grid. Without the grid, LibreOffice and Drive's importer ignore cell widths.""" tblPr = table._tbl.tblPr tw = OxmlElement("w:tblW"); tw.set(qn("w:w"), str(int(PAGE_W_IN * 1440))); tw.set(qn("w:type"), "dxa"); tblPr.append(tw) lay = OxmlElement("w:tblLayout"); lay.set(qn("w:type"), "fixed"); tblPr.append(lay) for gc, w in zip(table._tbl.tblGrid.findall(qn("w:gridCol")), widths): gc.set(qn("w:w"), str(int(PAGE_W_IN * 1440 * w / 100)))

def add_runs(par, text, size=BODY_PT, color=INK, bold=False): """Inline bold and italic; nothing else.""" for part in re.split(r"(**[^]+**|*[^]+*)", text): if not part: continue b, i = bold, False if part.startswith("") and part.endswith(""): part, b = part[2:-2], True elif part.startswith("") and part.endswith("") and len(part) > 2: part, i = part[1:-1], True r = par.add_run(part) r.bold, r.italic = b, i r.font.size, r.font.color.rgb, r.font.name = Pt(size), color, FONT

def para(doc, text="", size=BODY_PT, color=INK, space_after=6, bold=False, align=None): p = doc.add_paragraph() p.paragraph_format.space_after = Pt(space_after) p.paragraph_format.line_spacing = 1.2 if align: p.alignment = align add_runs(p, text, size, color, bold) return p

def is_num(s): s = s.replace("**", "").strip() return bool(s) and bool(NUM_RE.match(s))

def auto_widths(rows): """Width by the longest token a column must show unbroken, capped, first column widest.""" n = len(rows[0]) need = [] for c in range(n): cells = [r[c].replace("**", "") for r in rows if c < len(r)] longest_word = max((len(w) for v in cells for w in v.split()), default=4) typical = sorted(len(v) for v in cells)[len(cells) // 2] if cells else 4 need.append(max(longest_word, min(typical, 28 if c == 0 else 14), 5)) tot = sum(need) return [w / tot * 100 for w in need]

def add_table(doc, rows, widths=None): header, body = rows[0], rows[1:] n = len(header) widths = widths or auto_widths(rows) numeric = [c > 0 and sum(is_num(r[c]) for r in body if c < len(r)) >= max(1, len(body) * 0.6) for c in range(n)] t = doc.add_table(rows=len(rows), cols=n) t.alignment = WD_TABLE_ALIGNMENT.LEFT t.autofit = False set_cell_margins(t) fix_grid(t, widths) for ri, row in enumerate(rows): is_head = ri == 0 filled = [c.strip() for c in row if c.strip()] is_group = not is_head and bool(filled) and all(c.startswith("") and c.endswith("") for c in filled) for ci in range(n): cell = t.cell(ri, ci) cell.width = Inches(PAGE_W_IN * widths[ci] / 100) txt = row[ci].strip() if ci < len(row) else "" p = cell.paragraphs[0] p.paragraph_format.space_after = Pt(0) p.paragraph_format.line_spacing = 1.1 if numeric[ci]: p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if is_head: add_runs(p, txt.replace("**", ""), HEAD_PT, MUTED, bold=True) set_cell_borders(cell, bottom=(8, HEAD_RULE)) else: add_runs(p, txt, TABLE_PT, INK, bold=is_group) set_cell_borders(cell, top=(6, "9AA0A6") if is_group and ri > 1 else None, bottom=(4, RULE)) if is_group: set_cell_fill(cell, GROUP_FILL) # repeat header row across pages, keep rows from splitting trPr = t.rows[0]._tr.get_or_add_trPr(); h = OxmlElement("w:tblHeader"); h.set(qn("w:val"), "true"); trPr.append(h) for r in t.rows: cs = OxmlElement("w:cantSplit"); cs.set(qn("w:val"), "true"); r._tr.get_or_add_trPr().append(cs) para(doc, "", space_after=4)

def render_chart(spec, out_png): import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.ticker # noqa: F401 from matplotlib import font_manager names = {f.name for f in font_manager.fontManager.ttflist} fam = next((n for n in (FONT, "Helvetica Neue", "Arial") if n in names), "DejaVu Sans") plt.rcParams.update({"font.family": fam, "font.size": 9, "axes.edgecolor": GREY_HEX, "axes.labelcolor": "#5F6368", "xtick.color": "#5F6368", "ytick.color": "#1F2328"}) kind, labels = spec["type"], spec["labels"] fmt = spec.get("fmt", "{:,.0f}") hl = set(spec.get("highlight", [])) h = spec.get("height") or (max(1.6, 0.32 * len(labels) + 0.5) if kind in ("hbar", "funnel") else 2.4) fig, ax = plt.subplots(figsize=(PAGE_W_IN, h), dpi=200) for s in ("top", "right"): ax.spines[s].set_visible(False) if kind in ("hbar", "funnel"): vals = spec["values"] colors = [ACCENT_HEX if (not hl or l in hl) else GREY_HEX for l in labels] y = range(len(labels))[::-1] if kind == "funnel": colors = [ACCENT_HEX] * len(labels) if spec.get("log", True): ax.set_xscale("log") ax.xaxis.set_minor_locator(matplotlib.ticker.NullLocator()) ax.barh(list(y), vals, color=colors, height=0.62) ax.set_yticks(list(y)); ax.set_yticklabels(labels) ax.spines["left"].set_visible(False); ax.spines["bottom"].set_visible(False) ax.set_xticks([]); ax.xaxis.set_minor_locator(matplotlib.ticker.NullLocator()); ax.tick_params(axis="y", length=0) vmax = max(vals) or 1 for yi, v in zip(y, vals): ax.text(v * (1.04 if kind == "funnel" else 1) + (0 if kind == "funnel" else vmax * 0.015), yi, fmt.format(v), va="center", fontsize=8.5, color="#1F2328", bbox=dict(boxstyle="square,pad=0.15", fc="white", ec="none"), zorder=5) if "ref" in spec: ax.axvline(spec["ref"], color="#5F6368", lw=0.8, ls=(0, (3, 3))) ax.text(spec["ref"], len(labels) - 0.4, " " + spec.get("ref_label", "avg"), fontsize=8, color="#5F6368") ax.set_xlim(right=vmax * (3 if kind == "funnel" else 1.15)) else: series = spec["series"] palette = [ACCENT_HEX, "#9AA0A6", "#F29D38", "#34A853"] x = range(len(labels)) if kind == "bar": k = len(series); w = 0.8 / k for i, (name, vals) in enumerate(series.items()): xs = [xi - 0.4 + w * (i + 0.5) for xi in x] bars = ax.bar(xs, vals, w * 0.9, label=name, color=palette[i % 4]) for b, v in zip(bars, vals): ax.text(b.get_x() + b.get_width() / 2, b.get_height(), fmt.format(v), ha="center", va="bottom", fontsize=7.5, color="#1F2328") else: for i, (name, vals) in enumerate(series.items()): ax.plot(list(x), vals, marker="o", ms=3.5, lw=1.8, label=name, color=palette[i % 4]) ax.text(len(labels) - 1 + 0.08, vals[-1], fmt.format(vals[-1]), va="center", fontsize=8, color=palette[i % 4]) ax.set_xticks(list(x)); ax.set_xticklabels(labels) ax.yaxis.grid(True, color="#EEF0F2", lw=0.8); ax.set_axisbelow(True) ax.spines["left"].set_visible(False); ax.tick_params(axis="y", length=0, labelcolor="#5F6368") if len(series) > 1: ax.legend(frameon=False, fontsize=8, loc="upper left", ncol=len(series), bbox_to_anchor=(0, 1.12)) fig.tight_layout() fig.savefig(out_png, dpi=200, bbox_inches="tight") plt.close(fig)

def add_chart(doc, spec, tmpdir, idx): if spec.get("title"): para(doc, spec["title"], size=10, bold=True, space_after=2) png = Path(tmpdir) / f"chart{idx}.png" render_chart(spec, png) doc.add_picture(str(png), width=Inches(PAGE_W_IN)) if spec.get("note"): para(doc, spec["note"], size=8.5, color=MUTED, space_after=8)

def add_callout(doc, text): t = doc.add_table(rows=1, cols=1) set_cell_margins(t, 110, 110, 160, 160) fix_grid(t, [100]) c = t.cell(0, 0); c.width = Inches(PAGE_W_IN) set_cell_fill(c, CALLOUT_FILL); set_cell_borders(c) p = c.paragraphs[0]; p.paragraph_format.space_after = Pt(0) add_runs(p, text, BODY_PT) para(doc, "", space_after=2)

def build(md_text, out_path): doc = Document() sec = doc.sections[0] sec.left_margin = sec.right_margin = Inches(1); sec.top_margin = sec.bottom_margin = Inches(0.9) st = doc.styles["Normal"]; st.font.name = FONT; st.font.size = Pt(BODY_PT) st.element.rPr.rFonts.set(qn("w:eastAsia"), FONT) for lvl, size in ((1, 20), (2, 14), (3, 11.5)): hs = doc.styles[f"Heading {lvl}"] hs.font.name, hs.font.size, hs.font.bold, hs.font.color.rgb = FONT, Pt(size), True, INK hs.paragraph_format.space_before = Pt({1: 0, 2: 18, 3: 12}[lvl]) hs.paragraph_format.space_after = Pt(6 if lvl > 1 else 4) hs.paragraph_format.keep_with_next = True

lines = md_text.splitlines()
i, chart_n, pending_widths = 0, 0, None
tmp = tempfile.mkdtemp()
while i < len(lines):
    ln = lines[i].rstrip()
    m = re.match(r"<!--\s*table:\s*widths=([\d,\s.]+)-->", ln)
    if m:
        pending_widths = [float(x) for x in m.group(1).split(",")]; i += 1; continue
    if ln.startswith("```chart"):
        j = i + 1; buf = []
        while j < len(lines) and not lines[j].startswith("```"):
            buf.append(lines[j]); j += 1
        add_chart(doc, json.loads("\n".join(buf)), tmp, chart_n); chart_n += 1
        i = j + 1; continue
    if ln.startswith("|"):
        rows = []
        while i < len(lines) and lines[i].strip().startswith("|"):
            cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
            if not all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c):
                rows.append(cells)
            i += 1
        add_table(doc, rows, pending_widths); pending_widths = None; continue
    if ln.strip() == "---":
        doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE); i += 1; continue
    if ln.startswith("#"):
        lvl = len(ln) - len(ln.lstrip("#"))
        h = doc.add_heading(level=min(lvl, 3)); add_runs(h, ln.lstrip("# ").strip(), {1: 20, 2: 14, 3: 11.5}[min(lvl, 3)], INK, True)
        i += 1; continue
    if ln.startswith("> "):
        buf = []
        while i < len(lines) and lines[i].startswith(">"):
            buf.append(lines[i].lstrip("> ").strip()); i += 1
        add_callout(doc, " ".join(buf)); continue
    if re.match(r"^(\s*)([-*]|\d+\.)\s", ln):
        numbered = bool(re.match(r"^\s*\d+\.", ln))
        p = doc.add_paragraph(style="List Number" if numbered else "List Bullet")
        p.paragraph_format.space_after = Pt(3)
        add_runs(p, re.sub(r"^\s*([-*]|\d+\.)\s", "", ln)); i += 1; continue
    if ln.startswith("_") and ln.endswith("_"):
        para(doc, ln.strip("_"), size=8.5, color=MUTED); i += 1; continue
    if ln.strip():
        p = para(doc, ln.strip())
        nxt = next((l for l in lines[i + 1:] if l.strip()), "")
        if nxt.startswith(("|", "<!-- table", "```chart")):
            p.paragraph_format.keep_with_next = True  # a table's label never strands at a page foot
    i += 1
doc.save(out_path)

def main(): ap = argparse.ArgumentParser(description=doc, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("md"); ap.add_argument("-o", "--out"); ap.add_argument("--pdf", action="store_true") a = ap.parse_args() out = Path(a.out or Path(a.md).with_suffix(".docx")) build(Path(a.md).read_text(), out) print(f"wrote {out}") if a.pdf: subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "--outdir", str(out.parent), str(out)], check=True, capture_output=True) print(f"wrote {out.with_suffix('.pdf')}")

if name == "main": main()

references/example-review.md

Acme Freight: three months in, and what comes next

Fictional example · Draft for Dana · Data Aug 1 to Oct 25

1. Summary

11 booked meetings, 3 open deals, and a clear answer on who buys. Mid-size regional carriers reply; national carriers and middle management do not. Next quarter's target is 2+ meetings a week.

  • Reach: 12,400 people at 6,100 accounts, by email and LinkedIn.
  • Biggest leak is at the bottom: 24 people said "let's book a call"; 9 became a meeting. Closing that gap costs no extra sends.
  • Referrals beat every channel: about 6x the interest rate of cold email, and 3 of the 11 meetings.
  • Top qualified objection: "we're locked into a contract". A renewal-date play answers it.

2. The numbers

{"type":"funnel","title":"From 41,000 scored accounts to 11 meetings","labels":["Accounts scored","Qualified accounts","People emailed","People who replied","Positive replies","Said book a call","Booked meetings"],"values":[41000,6100,11800,190,80,24,11],"note":"All channels, Aug 1 to Oct 25. A reply is a human reply; auto-replies and out-of-office are excluded."}
<!-- table: widths=28,18,18,18,18 -->
MonthPeople emailedReply ratePositiveBook a call
August1,5000.90%62
September5,6001.10%246
October (to 25th)4,7001.30%226
Total email11,8001.15%5214
Total LinkedIn6009.0%2810

LinkedIn produces about 10x the positive rate of email per person. October replies are still arriving.

By campaign

<!-- table: widths=34,14,14,14,24 -->
CampaignPeopleReply ratePositive rateMeetings
Regional carriers4,2001.60%0.80%6
New fleet manager1,5001.90%1.00%4
Lane expansion2,7001.40%0.70%2
Brokers3,9001.00%0.30%2
Broker cold3,9001.00%0.30%2
Tests3,7000.70%0.20%n/a

Campaigns with 100+ people reached. Full detail is in the appendix.

3. How we get more meetings

<!-- table: widths=26,44,30 -->
LeverWhat we will doExpected impact
Convert existing yesesSame-day replies with a calendar link; chase all 15 open yeses to a booked call or a closeHighest, free. Half of yeses booking adds about 7 meetings
Tighter targetingWeight lists to 50-500 staff and owner or ops contacts; drop national carriers from cold emailHigh. Same sends, segments that reply 2x as often
Ask for referralsAsk every happy customer for one intro each quarterHigh per intro; capped by customer count. Untested at volume

4. Where we have not seen success

<!-- table: widths=30,22,48 -->
SegmentResultWhat we take from it
Carriers over 5,000 staff0 of 900 interestedWork them through referrals, not cold email
Owner-operators0 of 400Too small to need it; move them to DQ
Job-posting signal180 emailed, 0 repliesSignal fires wide; tighten the fit filter before it sends

5. What we will improve

  • Reply speed and follow-through: every positive reply answered the same business day, with one named owner chasing each yes.
  • Meeting tracking: connect a calendar so meetings are counted automatically, not by confirmation.
  • One test per live campaign at a time (opener, offer or schedule), so we keep learning without stopping anything.

6. New plays for next quarter

<!-- table: widths=42,58 -->
What we learnedWhat we will run
New fleet managers reply at 2x averageA dedicated campaign in their first 90 days
"We're locked into a contract" is the top qualified objectionA renewal-date play: capture the date, return 60 days before

7. Where we need Acme Freight to push

<!-- table: widths=40,60 -->
AskWhy it matters
Dana's LinkedIn seat, plus one more senderLinkedIn is 10x email per person; one seat caps volume
One named person who answers within a dayMost lost meetings die waiting on an answer
Sign-off on one customer storyUnblocks proof in step 3 of every sequence

Data appendix

A. TAM by industry and fit

<!-- table: widths=36,16,16,16,16 -->
IndustryAccountsTier AReachedInterested / 1k
Regional carriers2,1004201,5009.0
Freight brokers1,9002601,2003.1
Cold chain8001505004.0

All numbers in this example are fictional.

B. Who replies

{"type":"hbar","title":"Mid-size carriers reply 3x more than large ones","labels":["50-200","201-500","501-5,000","Over 5,000"],"values":[8.1,6.2,2.7,0.0],"fmt":"{:.1f}","highlight":["50-200","201-500"],"ref":5.0,"ref_label":"avg 5.0","note":"Interested people per 1,000 reached, by company size."}

C. Objections

<!-- table: widths=26,10,64 -->
What they saidAcctsHow we answer it
Talk to my colleague12Treat as a warm intro: contact the named person, using the referrer's name
Locked into a contract10Capture the renewal date and return 60 days before
We built it in-house7Cost comparison against their own stack
references/slack-message.md

Slack message to the client team

Send it when the doc goes out (T-3 business days), in the shared client channel, from the account owner. Share the doc as Commenter with everyone tagged before posting, and attach the Loom if there is one.

Tone follows writing-rules.md "Tone: honest and positive": open warm, put the wins and momentum first, frame each gap as upside with its fix. Rules: under 200 words (renewal version) or 120 (routine); the link comes first; three findings at most, each with a number; ask for at most three decisions; give a comment deadline and a call ask. No headers, and no bullets nested inside bullets. Edit it for AI patterns.

Template

@<founder> @<others> our <period> review is ready: <doc link>

Short version: <one-line result, with the meeting and deal counts>.

Three things worth your eyes:
• <finding 1, with its number>
• <finding 2, with its number>
• <finding 3, with its number>

Where we need you (section 7): <ask 1>, <ask 2>, <ask 3>.

Comment anywhere in the doc by <day>. Then let's take 45 min on <day/time> to walk through your comments and the plan for next quarter.

When the minimum term is ending

Open with the timing ("ahead of our 3-month minimum closing...") and add one block before the comment ask: up to 4 plays we would run next, each tied to something the data showed (pull from §6 of the doc). Frame them as an invitation, not a pitch: "If you're game to keep going, here's what we'd explore together:".

Example (fictional client)

@Dana @Priya our three-month review is ready: <link>

Short version: 11 booked meetings and 3 open deals so far, and a clear picture of who buys.

Three things worth your eyes:
• 24 people said "let's book a call" and 9 became meetings. That's the cheapest gap to close
• Referral asks convert about 6x better than cold email (3 of the 11 meetings)
• "We're locked into a contract" is the top objection from qualified teams, which a renewal-date play answers

Where we need you (section 7): a second LinkedIn sender, one person who answers prospects within a day, and sign-off on the customer story.

Comment anywhere in the doc by Thursday. Then let's take 45 min Friday to walk through it and lock the plan.
references/writing-rules.md

Writing rules: concise, dense, clean

Tone: honest and positive

The review has to make the client want to keep going, and every line still has to survive their own check of the numbers.

  • Lead with what worked, then the momentum (month over month: "October was our best reply rate yet"). Trend beats a total.
  • Frame a gap as upside with its fix: "24 said book a call, 9 have booked, so converting the rest is the cheapest win", not "only 9 converted".
  • Give a learning as an asset: "we now know who buys" is a result of the quarter, even when meetings are modest.
  • Turn an objection into a play: name it, then the answer we'll run.
  • Name what didn't work plainly, once (section 4), with what we take from it. Candour is what makes the positives believable.
  • Say "together" and "we", credit the client's part (events, the founder's posts), and make the asks about getting there faster.
  • Show continuity: the system keeps running and stays in their stack, so next quarter starts at full speed.
  • Never inflate: no rounding up, no unconfirmed meetings counted, no "huge" or "incredible". Write "we're excited" once at most and back it with a number.

Prose

  • Each section starts with one sentence stating its takeaway. The reader should be able to read only those sentences and get the review.
  • Use numbers, not adjectives: "0 of 900", not "very few". Give one comparison per claim ("6x cold email").
  • Short paragraphs, 1 to 3 sentences. Use bullets for parallel items, prose for reasoning.
  • Name companies when it makes a pattern concrete ("one prospect sent their rate sheet"), never as filler.
  • Define a term once, in a footnote line under the first table that uses it (_..._ renders small and grey).

Tables (why the Google Docs version looked cramped, and the fix)

The cramped look came from five things: no column widths, so Docs splits the page evenly and the long column wraps; two values in a cell ("32 (1.64%)"); long headers ("Replied (#, %)"); a border on every side of every cell; and coloured fills on every group.

  • At most 5 columns in the body, 6 in the appendix. If you need more, split the table or make a chart.
  • One value per cell. Pick the number the reader compares (usually the rate) and drop or move the count. If both matter, give them two columns.
  • Headers of 1 to 3 words. Put units in the header ("Reply rate"), not in every cell.
  • First column is the label and the widest. Set widths with <!-- table: widths=34,14,14,14,24 --> whenever the auto split wraps a label.
  • Numbers right-aligned (the script does this) with the same decimals down a column: percentages to 2 places under 10%, 1 place above that.
  • A group or total row has every filled cell bold (| **Group A** | **1,200** | ...); the script adds the fill and the rule. Do not colour-code rows.
  • At most about 12 rows in the body. Show the top rows by the metric and send the rest to the appendix.
  • Use an em dash for "none", never a blank cell. Give a table title only when the section heading doesn't already say it.

Charts

  • Draw a chart only when the shape is the point: the funnel, a rate by segment against the average, or a trend over months. Otherwise use a table.
  • The title is the takeaway ("Smaller companies reply 5x more"), never the axis name.
  • Use one accent colour on the bars that carry the point ("highlight") and grey on the rest. Draw a dashed reference line for the average ("ref").
  • Label values directly on the bars; no gridlines on bar charts and no legends for a single series.
  • Use at most 2 charts in the body; the rest go in the appendix.

Length

  • Body (sections 1-7): about 4 pages. Appendix: as long as the data needs, but every table there still follows the rules above.
  • If a section has nothing to say this period, write that in one line. Don't pad.

Install this skill

Pick the app you use. We remember it for next time.

  1. Download the skill as a ZIP.
  2. In Claude, open Customize, then Skills, then Your skills.
  3. Choose Upload skill and pick the ZIP. Leave it switched on.
Download ZIPOpen Claude skills

Then ask: “three months in”

Want the whole library, kept up to date? .

Run it on your own playbookBook 30 minutes. We build it onto a Company Brain live.
Deliver and keep clientscollection
Customer Successcategory
MITlicense

Install this skill

Pick the app you use. We remember it for next time.

  1. Download the skill as a ZIP.
  2. In Claude, open Customize, then Skills, then Your skills.
  3. Choose Upload skill and pick the ZIP. Leave it switched on.
Download ZIPOpen Claude skills

Then ask: “three months in”

Want the whole library, kept up to date? .

Want this sounding like you?

Out of the box it runs on general knowledge. On a Company Brain it runs on your framework, your pricing and the proposals you’ve already won with, so the output comes back in your voice.

Book 30 minutes

More from Garrett

Sales

Cold email sequence that gets positive replies

Use this skill when an agency is writing, rewriting or reviewing cold email for a client: a full sequence, a subject line, first line, P.S., CTA, offer, follow-up step, spintax, a signal or trigger campaign, conference outreach, a segment and offer map, or an A/B test. Walks the decisions in order (value lever, list and signal, offer, subject, first line, body framework, CTA, follow-ups), lints the copy and sizes or reads tests. Also use for "build a sequence", "why isn't this campaign getting replies", "did variant B win".

Garrett WolfeFounder, OneGTM
Sales

Sent cold email content review

Use this skill when an agency needs to QA every cold email a client campaign has actually sent: names and company names rendering correctly, variables filled, message coherence, pitch accuracy, ICP fit, signal-to-message alignment, sender and signature consistency, CTA calibration and cross-campaign duplication. Produces findings tagged by severity and fix location with a prioritized action list. Also use for "review our emails", "QA our outbound", "are our emails going out correctly".

Garrett WolfeFounder, OneGTM
RevOps

TAM scoring audit and distribution fix

Use this skill when a client's account scoring model piles everyone at the same score and the agency needs to audit it against the data, simulate revised rubrics and recommend bands, weights and multipliers that make accounts stack-rank, with a DQ cutoff that means something. Works on a Clay or spreadsheet TAM export plus its rubric. Also use for "rescore this list", "fix the scoring", "why is everyone scoring the same", "distribution is compressed", "twin-firm test".

Garrett WolfeFounder, OneGTM

More in Deliver and keep clients

Customer Success

Client activation check-in

Use this skill before a check-in with a client who has signed but whose work is not yet live with the people it was built for, when the call is about to become another status update. Produces a prep card: the one milestone this call exists to move, where they actually are against it, what is blocking it, and the agenda to send. Also use for "prep my check-in with [client]", "they signed but nothing's happening", "client call prep", "onboarding call".

Bailey DarlingBailey DarlingCo-Founder, Gia
Customer Success

Client churn autopsy

Use this skill in the week after a client leaves, while the record is still readable and before the story settles into whatever is least uncomfortable. Produces the real cause separated from the stated one, the point the engagement actually turned, what would have caught it, and whether this one is winnable back. Also use for "they churned", "why did we lose them", "post-mortem on the account", "can we get them back".

Bailey DarlingBailey DarlingCo-Founder, Gia
Sales Coaching

Client list and stack pitch

Use this skill when a rep's account list needs reviewing: a list meeting is due, the book is over capacity, or expansion revenue has gone quiet. Produces a prioritised table of every account with tier, days since last meaningful touch, health status (Healthy, Inactive Alert, Eviction Candidate, Flag On Track, Flag Expired) and an account-specific stack pitch angle, plus a capacity line and the week's 3 to 5 moves. Also use for "list review", "rent the list", "which accounts should Danny drop", "eviction candidates", "stack pitch", "expansion angles for my book".

Tony DowlingTony DowlingDirector, CONVRG