The short answer
A quarterly business review (QBR) is a recurring meeting where a vendor shows a customer what the product delivered and what to do next. Most of the prep is gathering numbers and pasting them into slides. A GTM engineer automates that part: a script pulls usage data, computes the value in code, fills a branded deck, and hands a draft to the Customer Success Manager (CSM) to review. The hard parts are honest ROI math and keeping an AI model away from the numbers.
Why this matters
The roadmap behind this course says: "CSMs should be strategic advisors not manual data gatherers." Treat that as a claim to test.
Is the time going to data work? The Bain Customer Success Practitioner Survey (August 2025, 235 practitioners) reports that CSMs spend about two-thirds of their time (a figure labeled 65% in one chart caption) on lower-value activities that AI could automate. It is a survey, not a time study, and the "lower value" label is Bain's.
Does a QBR protect revenue? I found no independent study linking QBRs to retention. Some vendor blogs quote retention lifts from QBRs with no method, and I found no study behind them. Gainsight's QBR guide says B2B customers with strong executive engagement are "2.5 times more likely to renew" and credits McKinsey. I could not trace that to McKinsey, and it measures executive engagement, not QBRs. The claim that a 5% retention gain lifts profit 25% to 95% is usually credited to Zero Defections (Harvard Business Review, 1990). The page I fetched shows no such figure. Keep all of these off your slides.
Does automation improve the meeting? Not by itself. Gainsight's EBR advice says an executive review is not a status update, and to pick 3 to 5 themes, not a data dump. A script that dumps more charts repeats that mistake faster.
Verdict. Half right. Code does the gathering and formatting well. The CSM decides what the numbers mean and what the customer does next.
Core concepts
QBR, EBR and the agenda
Vendors use the terms loosely. Gainsight's EBR post draws this line:
| QBR | EBR | |
|---|---|---|
| Cadence | Every quarter | Every 6 months for strategic accounts, yearly for growth accounts |
| Audience | Managers, team leads, users | C-suite, VPs, directors |
| Focus | Usage and tactical progress | Business outcomes and strategic value |
Its 55 minute EBR agenda: welcome and goals (5), strategic updates (10), outcomes and value delivered (15), challenges and opportunities (10), recommendations and next steps (15). The QBR guide adds ROI tied to the customer's goals, progress against last quarter, a health score and action items with owners. Your script builds the value and progress slides. The CSM writes the recommendations.
Value realization: baseline, target, current
Value realization means showing the customer they got the result they bought. Usage is not value: "40 logins" proves nothing. A result needs a baseline from before, an agreed target, and a current number:
{
"goal": "Cut the time to build the weekly ops report",
"baseline": "18 minutes per report, measured at kickoff",
"target": "under 5 minutes",
"current": "2 minutes",
"status": "met"
}If nobody measured a baseline at kickoff, you cannot prove value later. Code cannot repair that.
ROI frameworks
Return on investment (ROI) is the net gain from a purchase divided by what it cost. Here are four ways to turn usage into money. Use the one you can defend.
| Framework | Formula | Weak spot |
|---|---|---|
| Time saved | tasks x share that replaces manual work x minutes saved / 60 x loaded hourly cost | Saved hours are not saved dollars unless the time is used elsewhere |
| Cost avoided | Spend that did not happen, such as a cancelled contractor | Count it only if the cost really disappeared |
| Revenue gained | Extra revenue the customer earned because of the product | Needs a counterfactual (what would have happened without the product, for example from a control group), or it is a guess |
| Payback period | Upfront cost / monthly net benefit | Hides how reliable the inputs are |
Loaded hourly cost is wage plus the employer's cost of benefits and taxes. The US Bureau of Labor Statistics reported on September 9, 2026 that private industry employers paid $46.89 per hour worked in June 2026, of which $32.82 was wages. So a loaded rate is about 1.43 times the wage. That is an all-industry average. Ask the customer for their own.
The project uses mock data. Acme Logistics completed 3,996 tasks in Q3 2026:
| Step | Calculation | Result |
|---|---|---|
| Tasks that replaced manual work | 3,996 x 30% (sampled with the customer) | 1,199 |
| Minutes saved per task | 18 before, 2 after | 16 |
| Measured hours saved | 1,199 x 16 / 60 | 320 |
| Realized hours (80% haircut) | 320 x 0.80 | 256 |
| Hourly cost | $45 wage x 1.43 | $64.29 |
| Labor value | 256 x $64.29 | $16,442 |
| License cost this quarter | from the contract | $6,000 |
| ROI | (16,442 - 6,000) / 6,000 | 174% |
| Payback on a $4,000 onboarding fee | 4,000 / ((16,442 - 6,000) / 3) | 1.1 months |
A haircut is a deliberate cut to an estimate: here you count only 80% of measured savings. The table rounds each step, but the code keeps full precision, so 256 x $64.29 looks like $16,458 while the code prints $16,442. The 30% share and the 80% haircut are what a skeptical customer will attack, so the deck shows them.
Risk adjustment. Forrester's Total Economic Impact (TEI) method models benefits, costs, flexibility and risk. In a TEI study commissioned by Cloudflare, Forrester cut benefits by 5% to 15% and raised implementation costs by 5%. Vendors commission these studies, so they show a method, not proof. Copy the habit: shrink benefits, inflate costs.
Chart rules
Edward Tufte's The Visual Display of Quantitative Information (1983) covers the data-ink ratio (the share of a chart's ink that shows data) and graphical integrity (charts that do not mislead). Cole Nussbaumer Knaflic's Storytelling with Data (2015) teaches six lessons, among them choose the right display, cut clutter and tell a story. As rules a script can follow:
- One message per chart, written as the slide title: "Tasks completed grew 34% quarter on quarter", not "Task volume".
- Pick the form by the question. Trend: a line. Compare a few items: bars.
- Remove what is not data. One series needs no legend. Drop gridlines.
- Use color to point. Grey for context, one brand color for the story.
- Show the source and the assumptions.
Three ways to generate the deck
| Route | You control | Watch out for |
|---|---|---|
| python-pptx | Every shape, native editable charts, offline | You place things in inches |
| Google Slides API | A template deck with tags like {{hours_saved}} | Quotas, scopes, public image URLs |
| Gamma API | Little, a model lays out the slides | Numbers can drift, so verify |
python-pptx writes real PowerPoint files with native charts. A native chart is a real chart object with its own data sheet, not a picture, so the CSM can open it and fix a number. The docs show CategoryChartData and slide.shapes.add_chart(chart_type, x, y, cx, cy, chart_data). For charts it cannot draw, save a PNG with matplotlib and call add_picture.
Google Slides API works in three steps: copy the template with the Drive API, send one presentations.batchUpdate (POST https://slides.googleapis.com/v1/presentations/{presentationId}:batchUpdate) with replaceAllText requests, and optionally replaceAllShapesWithImage. Never edit the template itself. An image must sit at a public URL, as PNG, JPEG or GIF, under 50 MB and 25 megapixels. Google's scopes page lists the non-sensitive drive.file scope as recommended. It covers files the app created or the user opened with it, so copying a hand-made template may need a file picker or the broader drive scope (untested). Writes are limited to 60 requests per minute per user, and Google's quota page says charges for going over quota are planned for later in 2026.
Gamma is the fast route. POST https://public-api.gamma.app/v1.0/generations takes inputText, format, textMode (generate, condense or preserve), numCards and exportAs (pdf, pptx or png). It returns a generationId, which you poll at GET /generations/{id} every 5 seconds until status is completed or failed. The key goes in an X-API-KEY header. If you use it, send your finished text with textMode: preserve and check the export against your facts.
The LLM summary, with guardrails
A model can turn a table of facts into two clear sentences. It will also invent a plausible number. The rule: numbers are computed in code, and the model never writes one. The design:
- Code computes every number and stores it as a fact with an id, such as
hours_saved. - The model gets ids and labels, and writes
{hours_saved}where a number goes. - Code rejects any reply with a digit outside a placeholder, or an unknown id.
- Code fills the placeholders with formatted values.
- If the model fails twice, a fixed template writes the slide.
Claude's structured outputs force the reply into a schema. A schema is the shape a reply must have. You describe it as a Pydantic class (a Python class that checks its data), pass it to client.messages.parse(..., output_format=Summary) and read parsed_output. The docs list unsupported keywords, including minLength and maxLength, so check lengths in code. Anthropic's guide to reducing hallucinations says its techniques "don't eliminate them entirely". The digit check also misses words like "doubled", so a human review still follows.
The model is claude-sonnet-5-5, listed at $2 per million input tokens and $10 per million output tokens. At about 600 input and 150 output tokens per deck, my estimate is under one US cent.
The review gate and the schedule
The CSM is the last check, so build it in. The script writes a draft with a "DRAFT" stamp, a placeholder on the recommendations slide, and a checklist of every number. A second script refuses to make the final file while a placeholder or an unchecked box remains.
For scheduling, a cron job on GitHub Actions is enough. Cron is a five-field schedule format: minute, hour, day of month, month, day of week. GitHub reads it as UTC unless you set a timezone. Runs can be delayed at the start of every hour, so pick an odd minute. In a public repository, scheduled workflows are disabled after 60 days without repo activity. For a live dashboard, Data Studio (the new name of Looker Studio) emails a PDF on a schedule, at most daily on standard accounts.
Key terms
The tools
| Tool | What it does | Pricing model | Notes |
|---|---|---|---|
| python-pptx | Creates and edits .pptx files, with native charts and tables | Free, MIT license | Version 1.0.2, released August 7, 2024. PyPI lists Python 3.8 to 3.12. The project code ran fine for me on 3.14 |
| matplotlib | Draws custom charts as images | Free, PSF-based license | For charts PowerPoint cannot make |
| Google Slides API | Copies a template deck and fills it with data | Free within quotas | Google says charges for going over quota are planned later in 2026 |
| Gamma API | Generates a deck from text, or from a Gamma template | Credits, on Pro, Ultra, Teams and Business plans | Text costs 1 to 3 credits per card. Gamma's docs say API keys need a paid plan, so there is no free API tier |
| Data Studio | Dashboards with scheduled PDF email | Free. Pro is a paid subscription | Looker Studio is now Data Studio. I did not find the Pro price |
| Gainsight | Customer success platform with health scores and success plans | Quote only, Essentials and Enterprise tiers | Gainsight's pricing page shows the two tiers but no prices. I did not verify current ownership, so check before you cite it |
| Claude API | Writes the summary through structured outputs | Per token. Sonnet 5.5 is $2 input and $10 output per million tokens | The project runs without a key through its fixed fallback text |
| GitHub Actions | Runs the script on a schedule | Free on public repos. 2,000 minutes a month on the Free plan for private repos | Standard runners only |
Build the project
You will build a small pipeline: weekly usage rows go in, one quarter of value is computed, and a branded draft deck comes out. A CSM edits the draft and a second script finalizes it. Everything runs on generated mock data, so you need no company or paid account. The Claude call is optional.
| Piece | File | Job |
|---|---|---|
| Mock data | make_data.py | Two accounts and 26 weeks of usage |
| Metrics | metrics.py | Every number, with ROI and payback |
| Narrative | narrative.py | Model summary with placeholders, or fixed fallback |
| Deck | deck.py | Branded 16:9 deck with a native chart |
| Command | build_qbr.py | Runs it all and writes the review checklist |
| Gate | finalize.py | Blocks a draft that still has placeholders |
| Optional | gslides.py | Google Slides merge |
- Create a project folder and a virtual environment inside it.
- Install the packages with the setup commands below.
- Save
make_data.pyand run it to createaccounts.csvandusage.csv. - Save
metrics.pyand read it. Every assumption, including the load factor and the haircut, sits at the top. - Save
narrative.pyandtest_narrative.py, then runpytestand watch the guardrail tests pass. - Save
deck.pyandbuild_qbr.py, then runpython build_qbr.py acme 2026Q3. - Open the draft in PowerPoint or Google Slides and check that each slide title states a finding.
- Replace the grey placeholder on the last slide with two real recommendations, then save the file under the same name.
- Open
out/review.md, check each number against the deck, and change each- [ ]to- [x]. - Run
python finalize.py out/acme-2026Q3-DRAFT.pptxand open the final file it writes. - Set
ANTHROPIC_API_KEY, rebuild with--llm, and compare the model's summary with the fallback. Skip this step if you have no key. - Save the GitHub Actions file as
.github/workflows/qbr.ymlso the script runs each quarter.
Setup commands for steps 1 and 2. Run them inside the project folder:
python -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
pip install pandas python-pptx pydantic pytest anthropicmake_data.py:
"""Make mock accounts and weekly usage. Run once: python make_data.py"""
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
accounts = pd.DataFrame([
("acme", "Acme Logistics", 40, 6000, 4000, 18, 2, 0.30, 45.0),
("globex", "Globex Health", 25, 4500, 3000, 25, 3, 0.25, 52.0),
], columns=["account_id", "name", "seats", "license_per_quarter", "onboarding_fee",
"baseline_min", "tool_min", "displaced_share", "wage_per_hour"])
weeks = pd.date_range("2026-04-06", "2026-09-28", freq="W-MON") # Q2 and Q3 2026
rows = []
for a in accounts.itertuples():
ramp = np.linspace(0.45, 0.80, len(weeks)) # adoption grows
for w, r in zip(weeks, ramp):
wau = int(a.seats * r + rng.integers(-2, 3))
rows.append((a.account_id, w.date().isoformat(), wau,
int(wau * rng.integers(8, 14)))) # tasks per active user per week
usage = pd.DataFrame(rows, columns=["account_id", "week_start", "weekly_active_users", "tasks_completed"])
accounts.to_csv("accounts.csv", index=False)
usage.to_csv("usage.csv", index=False)
print(len(accounts), "accounts,", len(usage), "usage rows")metrics.py:
"""All numbers in the deck come from this file. The model never computes anything."""
from dataclasses import dataclass
import pandas as pd
# BLS ECEC, private industry, June 2026: $46.89 total per hour, $32.82 of it wages.
LOAD_FACTOR = 46.89 / 32.82 # about 1.43; replace with the customer's own number if known
REALIZATION = 0.80 # haircut: assume only 80% of measured time savings becomes real value
@dataclass
class Fact:
id: str
label: str
value: float
fmt: str # "int", "usd", "pct", "hours", "months"
def show(self) -> str:
v = self.value
if v != v: # NaN, for example payback when the net benefit is not positive
return "not reached"
return {"int": f"{v:,.0f}", "usd": f"${v:,.0f}", "pct": f"{v:.0%}",
"hours": f"{v:,.0f} hours", "months": f"{v:.1f} months"}[self.fmt]
def in_quarter(usage: pd.DataFrame, q: pd.Period) -> pd.DataFrame:
d = pd.to_datetime(usage["week_start"])
return usage[(d >= q.start_time) & (d <= q.end_time)]
def compute(account: pd.Series, usage: pd.DataFrame, q: pd.Period) -> dict[str, Fact]:
u = usage[usage.account_id == account.account_id]
cur, prev = in_quarter(u, q), in_quarter(u, q - 1)
tasks, tasks_prev = cur.tasks_completed.sum(), prev.tasks_completed.sum()
wau = cur.weekly_active_users.mean()
minutes_saved = account.baseline_min - account.tool_min
hours = tasks * account.displaced_share * minutes_saved / 60 * REALIZATION
loaded = account.wage_per_hour * LOAD_FACTOR
value = hours * loaded
cost = account.license_per_quarter
net_monthly = (value - cost) / 3
payback = account.onboarding_fee / net_monthly if net_monthly > 0 else float("nan")
f = [
Fact("tasks", "Tasks completed this quarter", tasks, "int"),
Fact("tasks_change", "Change in tasks vs last quarter", tasks / tasks_prev - 1, "pct"),
Fact("utilization", "Average weekly active users / licensed seats", wau / account.seats, "pct"),
Fact("hours_saved", "Hours saved (after 80% realization haircut)", hours, "hours"),
Fact("value", "Labor value of hours saved", value, "usd"),
Fact("cost", "License cost this quarter", cost, "usd"),
Fact("roi", "ROI: (value - cost) / cost", (value - cost) / cost, "pct"),
Fact("payback", "Payback on onboarding fee", payback, "months"),
]
return {x.id: x for x in f}narrative.py:
"""The model writes words. Code supplies every number."""
import json
import re
from pydantic import BaseModel
from metrics import Fact
MODEL = "claude-sonnet-5-5"
TOKEN = re.compile(r"\{(\w+)\}")
SYSTEM = (
"You write the executive summary slide of a customer business review. "
"You get a list of facts with ids. Refer to a number ONLY by writing its id in braces, "
"for example {hours_saved}. Never write a digit or a spelled-out number yourself. "
"Use only the facts given. If the facts do not support a claim, leave it out. "
"Write one headline sentence and exactly 3 short bullets. Plain words, no hype."
)
class Summary(BaseModel):
headline: str
bullets: list[str]
def check(s: Summary, facts: dict[str, Fact]) -> None:
for text in [s.headline, *s.bullets]:
unknown = set(TOKEN.findall(text)) - set(facts)
if unknown:
raise ValueError(f"unknown fact ids: {unknown}")
rest = TOKEN.sub("", text)
if re.search(r"\d", rest):
raise ValueError(f"model wrote its own digits: {text!r}")
if "{" in rest or "}" in rest:
raise ValueError(f"malformed placeholder: {text!r}")
if len(s.bullets) != 3:
raise ValueError("need exactly 3 bullets")
def render(s: Summary, facts: dict[str, Fact]) -> Summary:
def fill(t: str) -> str:
return TOKEN.sub(lambda m: facts[m.group(1)].show(), t)
return Summary(headline=fill(s.headline), bullets=[fill(b) for b in s.bullets])
def fallback() -> Summary:
return Summary(
headline="{hours_saved} saved this quarter, a {roi} return on the license.",
bullets=["{tasks} tasks completed, {tasks_change} versus last quarter.",
"{utilization} of licensed seats were active in an average week.",
"The investment pays back its onboarding fee in {payback}."])
def write_summary(facts: dict[str, Fact], client=None, tries: int = 2) -> Summary:
if client is not None:
table = json.dumps([{"id": f.id, "label": f.label, "value": round(float(f.value), 3)}
for f in facts.values()])
for _ in range(tries):
resp = client.messages.parse(
model=MODEL, max_tokens=800, system=SYSTEM,
messages=[{"role": "user", "content": f"Facts:\n{table}"}],
output_format=Summary)
try:
check(resp.parsed_output, facts)
return render(resp.parsed_output, facts)
except ValueError as e:
print("rejected model output:", e)
return render(fallback(), facts) # fallback is checked by the tests, not trusted blindlytest_narrative.py:
import pandas as pd
import pytest
import metrics
import narrative
from narrative import Summary
@pytest.fixture
def facts():
acct = pd.read_csv("accounts.csv").iloc[0]
return metrics.compute(acct, pd.read_csv("usage.csv"), pd.Period("2026Q3"))
def test_fallback_passes_its_own_check(facts):
narrative.check(narrative.fallback(), facts)
def test_model_digits_are_rejected(facts):
bad = Summary(headline="You saved 500 hours.", bullets=["{tasks}", "{roi}", "{cost}"])
with pytest.raises(ValueError):
narrative.check(bad, facts)
def test_unknown_fact_id_is_rejected(facts):
bad = Summary(headline="{made_up} saved.", bullets=["{tasks}", "{roi}", "{cost}"])
with pytest.raises(ValueError):
narrative.check(bad, facts)
def test_malformed_placeholder_is_rejected(facts):
bad = Summary(headline="{hours-saved} saved.", bullets=["{tasks}", "{roi}", "{cost}"])
with pytest.raises(ValueError):
narrative.check(bad, facts)deck.py:
"""Build the branded deck with python-pptx. Charts are native, so the CSM can edit them."""
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches, Pt
BRAND = {"navy": RGBColor(0x1F, 0x2A, 0x44), "teal": RGBColor(0x0E, 0x7C, 0x86),
"grey": RGBColor(0x6B, 0x72, 0x80), "font": "Calibri"}
def text(slide, x, y, w, h, s, size=18, bold=False, color="navy"):
tf = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)).text_frame
tf.word_wrap = True
for i, line in enumerate(s.split("\n")):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.text = line
p.font.size, p.font.bold, p.font.name = Pt(size), bold, BRAND["font"]
p.font.color.rgb = BRAND[color]
return tf
def new_slide(prs, title, footer, draft):
s = prs.slides.add_slide(prs.slide_layouts[6]) # layout 6 is blank in the default template
text(s, 0.6, 0.4, 12.1, 1.2, title, size=28, bold=True) # the title states the finding
text(s, 0.6, 6.95, 10, 0.4, footer, size=11, color="grey")
if draft:
text(s, 10.5, 6.95, 2.4, 0.4, "DRAFT: CSM review pending", size=11, bold=True, color="teal")
return s
def build(account_name, quarter_label, weekly, facts, summary, assumptions, out_path, draft=True):
prs = Presentation()
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5) # 16:9
foot = f"{account_name} | {quarter_label} business review | Source: product usage data"
s = prs.slides.add_slide(prs.slide_layouts[6])
text(s, 0.8, 2.4, 11.5, 1.2, f"{account_name}: {quarter_label} business review", size=40, bold=True)
text(s, 0.8, 3.8, 11.5, 0.6, "Value delivered and plan for next quarter", size=20, color="teal")
s = new_slide(prs, summary.headline, foot, draft)
text(s, 0.8, 2.0, 11.5, 4, "\n".join(summary.bullets), size=22)
ch = facts["tasks_change"].value
s = new_slide(prs, f"Tasks completed {'grew' if ch >= 0 else 'fell'} {abs(ch):.0%} quarter on quarter",
foot, draft)
cd = CategoryChartData(number_format="#,##0")
cd.categories = list(weekly["week_start"])
cd.add_series("Tasks completed per week", list(weekly["tasks_completed"]))
chart = s.shapes.add_chart(XL_CHART_TYPE.LINE, Inches(0.8), Inches(1.9), Inches(11.7),
Inches(4.8), cd).chart
chart.has_legend = False # one series, so no legend: the title carries the message
chart.value_axis.has_major_gridlines = False
chart.series[0].format.line.color.rgb = BRAND["teal"]
chart.series[0].format.line.width = Pt(3)
chart.series[0].smooth = False
s = new_slide(prs, f"{facts['hours_saved'].show()} saved: {facts['value'].show()} of labor value "
f"against {facts['cost'].show()} of license", foot, draft)
keys = ("tasks", "hours_saved", "value", "cost", "roi", "payback")
rows = [("Measure", "This quarter")] + [(facts[k].label, facts[k].show()) for k in keys]
t = s.shapes.add_table(len(rows), 2, Inches(0.8), Inches(1.9), Inches(11.7),
Inches(0.5 * len(rows))).table
for i, row in enumerate(rows):
for j, v in enumerate(row):
t.cell(i, j).text = v
t.cell(i, j).text_frame.paragraphs[0].font.size = Pt(18)
s = new_slide(prs, "How we calculated this, so you can challenge it", foot, draft)
text(s, 0.8, 1.9, 11.5, 4.5, "\n".join(f"- {a}" for a in assumptions), size=18)
s = new_slide(prs, "Next quarter: two decisions for you", foot, draft)
text(s, 0.8, 1.9, 11.5, 4, "[CSM: write the two recommendations and the ask]", size=22, color="grey")
s.notes_slide.notes_text_frame.text = "Fill in from the last call. Delete this note before sending."
prs.save(out_path)build_qbr.py:
"""python build_qbr.py acme 2026Q3 -> out/acme-2026Q3-DRAFT.pptx plus out/review.md"""
import argparse
from pathlib import Path
import pandas as pd
import deck
import metrics
import narrative
p = argparse.ArgumentParser()
p.add_argument("account_id")
p.add_argument("quarter")
p.add_argument("--llm", action="store_true")
a = p.parse_args()
accounts, usage = pd.read_csv("accounts.csv"), pd.read_csv("usage.csv")
acct = accounts[accounts.account_id == a.account_id].iloc[0]
q = pd.Period(a.quarter)
facts = metrics.compute(acct, usage, q)
client = None
if a.llm:
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
summary = narrative.write_summary(facts, client)
weekly = metrics.in_quarter(usage[usage.account_id == a.account_id], q)
assumptions = [
f"Minutes per task: {acct.baseline_min} before, {acct.tool_min} with the tool (measured at kickoff).",
f"{acct.displaced_share:.0%} of tasks replace manual work (sampled with your team).",
f"Hourly cost: ${acct.wage_per_hour:.2f} wage x {metrics.LOAD_FACTOR:.2f} load (BLS, June 2026).",
f"We count {metrics.REALIZATION:.0%} of measured savings.",
"Payback = onboarding fee / (monthly value - monthly license).",
]
Path("out").mkdir(exist_ok=True)
name = f"out/{a.account_id}-{a.quarter}-DRAFT.pptx"
deck.build(acct["name"], f"{q.year} Q{q.quarter}", weekly, facts, summary, assumptions, name)
Path("out/review.md").write_text("# Review checklist\n" + "\n".join(
f"- [ ] {f.label}: {f.show()}" for f in facts.values()) + "\n- [ ] Next-quarter slide written\n")
print("wrote", name)finalize.py:
"""python finalize.py out/acme-2026Q3-DRAFT.pptx
Run after the CSM edited the draft in PowerPoint. Refuses to ship a draft with open placeholders."""
import sys
from pathlib import Path
from pptx import Presentation
src = Path(sys.argv[1])
prs = Presentation(src)
open_items = []
for n, slide in enumerate(prs.slides, 1):
for shape in list(slide.shapes):
if not shape.has_text_frame:
continue
t = shape.text_frame.text
if "[CSM:" in t:
open_items.append(f"slide {n}: placeholder still present")
if t.startswith("DRAFT:"):
shape._element.getparent().remove(shape._element) # drop the draft stamp
if slide.has_notes_slide:
slide.notes_slide.notes_text_frame.text = "" # internal notes never go to the customer
if "- [ ]" in (src.parent / "review.md").read_text():
open_items.append("review.md has unchecked boxes")
if open_items:
raise SystemExit("Not final:\n" + "\n".join(open_items))
final = src.with_name(src.name.replace("-DRAFT", ""))
prs.save(final)
print("wrote", final)The Google Slides route is gslides.py. I tested the request builder offline but did not run it against a live Google account, so expect to fix auth setup. Install google-api-python-client and google-auth first. The Python quickstart covers enabling the API and credentials.
"""Fill a Google Slides template that holds tags like {{hours_saved}} and {{headline}}."""
import google.auth
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/presentations",
"https://www.googleapis.com/auth/drive.file"]
def requests_for(facts, summary, chart_url=None):
tags = {f"{{{{{k}}}}}": f.show() for k, f in facts.items()}
tags["{{headline}}"] = summary.headline
tags["{{bullets}}"] = "\n".join(summary.bullets)
reqs = [{"replaceAllText": {"containsText": {"text": tag, "matchCase": True},
"replaceText": value}} for tag, value in tags.items()]
if chart_url: # must be a public URL: PNG, JPEG or GIF, under 50 MB and 25 megapixels
reqs.append({"replaceAllShapesWithImage": {
"containsText": {"text": "{{chart}}", "matchCase": True},
"imageUrl": chart_url, "imageReplaceMethod": "CENTER_INSIDE"}})
return reqs
def make_deck(template_id, title, facts, summary, chart_url=None):
creds, _ = google.auth.default(scopes=SCOPES)
drive = build("drive", "v3", credentials=creds)
slides = build("slides", "v1", credentials=creds)
copy = drive.files().copy(fileId=template_id, body={"name": title}).execute()
deck_id = copy["id"] # never edit the template itself
slides.presentations().batchUpdate(
presentationId=deck_id,
body={"requests": requests_for(facts, summary, chart_url)}).execute()
return f"https://docs.google.com/presentation/d/{deck_id}/edit"The quarterly schedule computes the previous quarter at run time. The action versions are the latest major versions I saw on each repository today.
name: quarterly-qbr-drafts
on:
schedule:
- cron: "17 13 3 1,4,7,10 *" # 13:17 UTC on the 3rd of Jan, Apr, Jul, Oct
workflow_dispatch:
jobs:
draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- run: pip install pandas python-pptx pydantic anthropic
- run: python make_data.py # mock data only; swap for your real export
- run: |
Q=$(python -c "import pandas as pd; print(pd.Period(pd.Timestamp.now(), 'Q') - 1)")
python build_qbr.py acme "$Q" --llm
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- uses: actions/upload-artifact@v7
with:
name: qbr-drafts
path: out/What done looks like. The build gives a six-slide draft with a stamp, a native line chart, an assumptions slide and a checklist. The tests pass. A draft with an open placeholder fails finalize.py. A fake model reply that contains a digit is rejected and the deck uses fixed text instead.
Stretch goal. Replace the CSV files with a query on a real warehouse or product analytics export, loop over every account, and add a risks slide that appears only when seat utilization falls under a threshold you choose.
Common mistakes
- Counting usage as value. Logins and clicks are inputs. A baseline, a target and a current result are value.
- Skipping the baseline. Without a kickoff measurement, every ROI number is an opinion.
- Treating saved hours as money. If the time is not used on other work, no dollars reach the customer's books. Say "capacity freed" and apply a haircut.
- Letting the model write numbers. It will round or invent figures. Use placeholders and validate.
- Sending the draft unreviewed. One wrong figure costs more than the hours you saved.
- One chart per metric. Ten charts is a data dump. Pick 3 to 5 themes.
Check yourself
1. A customer's team logged in 4,000 times this quarter. Why is that a weak headline for a business review?
Why: Usage shows activity, not outcome, so the review must compare against a measured baseline.
2. Your tool saved 300 hours, and the customer's CFO says no money was saved. What is the strongest reason the CFO may be right?
Why: Time saved is capacity, and it turns into money only when it is redeployed or removes real spend.
3. What is the safest way to let an LLM write the executive summary?
Why: Code owns every number, so the model can only write words around values it cannot change.
4. Which chart title best follows the one-message-per-chart rule?
Why: An action title states the finding, so the reader knows what to look for before reading the chart.
5. A Forrester TEI study reports a 238% ROI for a vendor's product. What should you take from it?
Why: TEI studies are commissioned by vendors, so copy the method and use the customer's own inputs.
How to learn this faster
Learn first: the ROI formulas, then python-pptx text, table and chart basics. Skip for now: Gamma and custom slide masters.
Deliberate-practice drill: take a vendor case study with a big ROI claim. Rebuild the ROI from its own inputs, list every assumption, then redo it with a haircut and a payback period.
- Day 1: Read the two Gainsight posts and write a one-page agenda for a 100-seat customer.
- Day 2: Build the ROI table for the mock account in a spreadsheet, then match it to
metrics.py. - Day 3: Run the project and make one chart whose title states a finding.
- Day 4: Add the placeholder guardrail and write one failing test where the model writes a digit.
- Day 5: Add the review gate, schedule it, and list which numbers you would not show a customer yet.
Resources
- Gainsight: How to run an executive business reviewArticleFree
Agenda with timings, plus cadence by account type.
- Gainsight: The dos and don'ts of executive business reviewsArticleFree
Blunt on status updates and data dumps.
- Gainsight: The essential guide to QBRsArticleFree
QBR agenda and metrics. Verify its cited statistics before reuse.
- Bain: Customer success at a crossroadsResearchFree
The 2025 survey on automatable CSM time.
- US BLS: Employer costs for employee compensationResearchFree
Primary source for loaded hourly cost.
- Forrester: Total Economic Impact methodologyDocsFree
The four parts of the TEI model.
- python-pptx: Working with chartsDocsFree
Exact code for chart data, chart types and
add_chart. - Google Slides API: Merge data into a templateDocsFree
The copy-then-replace pattern, with Python code.
- Google Slides API: batchUpdate referenceDocsFree
Endpoint, scopes and all-or-nothing updates.
- Claude docs: Structured outputsDocsFree
Forcing a JSON schema reply, and what is unsupported.
- Claude docs: Reduce hallucinationsDocsFree
Techniques behind a guarded summary.
- Gamma developer docsDocsPaid
Generate, template and polling endpoints. Keys need a paid plan.
- Tufte: The Visual Display of Quantitative InformationBookPaid
The classic on graphical integrity.
- Wiley: Storytelling with DataBookPaid
Six lessons for business charts.
Next module
Stage 12 turns everything you built into proof: public GTM teardowns and a portfolio that shows hiring managers you can do this work.
Built the project and passed the quiz? Mark the stage done. Your progress stays in this browser only.
Talk to Claude instead of typing to it.
Ducktate turns what you say into clean text, on your Mac, and pastes it anywhere.
Get early access