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, OneGTMClient 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:
| Metric | Source (typical) | Definition to hold to |
|---|---|---|
| Accounts scored / qualified / top tier | warehouse fit table | current fit model only, never a retired one |
| Signals detected / qualified / reached | signal tables | count people for recurring signals (promotions fire weekly) |
| People emailed, emails sent, new people | Instantly analytics + sent log | people, not leads rows; dedupe across campaigns |
| LinkedIn reached / accepted / replied | HeyReach stats | |
| Replies | reply-bot / inbox log | human replies only: no auto-reply, OOO, bounce, own follow-ups |
| Positive replies | reply classifier | interest of any kind: book, info, referral, "later" |
| Said "book a call" | classifier / manual | the yes, before it becomes a meeting |
| Meetings booked | calendar / HubSpot / confirmed list | proof of a meeting, not a positive reply |
| Deals | CRM | open and closed, with stage |
| Objections | reply classifier, grouped | count 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.
- Summary: one callout (
> **Headline.** ...) plus at most 4 bullets: results, the biggest leak, the best channel, the top objection. - 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.
- How we get more meetings: levers ranked by expected impact: lever, what we'll do, expected impact. Say which ones are untested.
- 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.
- What we will improve: our own fixes (speed, tracking, routing, copy, tests). Bullets.
- New plays: "what we learned" → "what we will run". Each play tied to a number in section 2 or the appendix.
- 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
- 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. - Before the call, answer every factual comment in-thread; collect the open questions as the agenda.
- On the call: 5 min on the summary, then comments, then plays and asks. Leave with an owner and date for each ask.
- After: resolve comments, write
decisions/<date>-review.mdin 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.
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 % widthschart {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 -->
| Month | People emailed | Reply rate | Positive | Book a call |
|---|---|---|---|---|
| August | 1,500 | 0.90% | 6 | 2 |
| September | 5,600 | 1.10% | 24 | 6 |
| October (to 25th) | 4,700 | 1.30% | 22 | 6 |
| Total email | 11,800 | 1.15% | 52 | 14 |
| Total LinkedIn | 600 | 9.0% | 28 | 10 |
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 -->| Campaign | People | Reply rate | Positive rate | Meetings |
|---|---|---|---|---|
| Regional carriers | 4,200 | 1.60% | 0.80% | 6 |
| New fleet manager | 1,500 | 1.90% | 1.00% | 4 |
| Lane expansion | 2,700 | 1.40% | 0.70% | 2 |
| Brokers | 3,900 | 1.00% | 0.30% | 2 |
| Broker cold | 3,900 | 1.00% | 0.30% | 2 |
| Tests | 3,700 | 0.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 -->| Lever | What we will do | Expected impact |
|---|---|---|
| Convert existing yeses | Same-day replies with a calendar link; chase all 15 open yeses to a booked call or a close | Highest, free. Half of yeses booking adds about 7 meetings |
| Tighter targeting | Weight lists to 50-500 staff and owner or ops contacts; drop national carriers from cold email | High. Same sends, segments that reply 2x as often |
| Ask for referrals | Ask every happy customer for one intro each quarter | High per intro; capped by customer count. Untested at volume |
4. Where we have not seen success
<!-- table: widths=30,22,48 -->| Segment | Result | What we take from it |
|---|---|---|
| Carriers over 5,000 staff | 0 of 900 interested | Work them through referrals, not cold email |
| Owner-operators | 0 of 400 | Too small to need it; move them to DQ |
| Job-posting signal | 180 emailed, 0 replies | Signal 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 learned | What we will run |
|---|---|
| New fleet managers reply at 2x average | A dedicated campaign in their first 90 days |
| "We're locked into a contract" is the top qualified objection | A renewal-date play: capture the date, return 60 days before |
7. Where we need Acme Freight to push
<!-- table: widths=40,60 -->| Ask | Why it matters |
|---|---|
| Dana's LinkedIn seat, plus one more sender | LinkedIn is 10x email per person; one seat caps volume |
| One named person who answers within a day | Most lost meetings die waiting on an answer |
| Sign-off on one customer story | Unblocks proof in step 3 of every sequence |
Data appendix
A. TAM by industry and fit
<!-- table: widths=36,16,16,16,16 -->| Industry | Accounts | Tier A | Reached | Interested / 1k |
|---|---|---|---|---|
| Regional carriers | 2,100 | 420 | 1,500 | 9.0 |
| Freight brokers | 1,900 | 260 | 1,200 | 3.1 |
| Cold chain | 800 | 150 | 500 | 4.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 said | Accts | How we answer it |
|---|---|---|
| Talk to my colleague | 12 | Treat as a warm intro: contact the named person, using the referrer's name |
| Locked into a contract | 10 | Capture the renewal date and return 60 days before |
| We built it in-house | 7 | Cost 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.