Home / Learn / GTM Engineering / Stage 11: The automated QBR

Stage 11 of 1223 min readUpdated 2026-10-07

Stage 11: The automated QBR

Learn to turn customer usage data into a branded ROI deck: ROI math, chart rules, a guarded AI summary, a CSM review gate and a quarterly schedule.

  • Build: Build a script that pulls customer usage data and writes a branded ROI deck with a guarded AI summary, which a CSM reviews before it ships.
  • Free, no signup

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:

QBREBR
CadenceEvery quarterEvery 6 months for strategic accounts, yearly for growth accounts
AudienceManagers, team leads, usersC-suite, VPs, directors
FocusUsage and tactical progressBusiness 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.

FrameworkFormulaWeak spot
Time savedtasks x share that replaces manual work x minutes saved / 60 x loaded hourly costSaved hours are not saved dollars unless the time is used elsewhere
Cost avoidedSpend that did not happen, such as a cancelled contractorCount it only if the cost really disappeared
Revenue gainedExtra revenue the customer earned because of the productNeeds a counterfactual (what would have happened without the product, for example from a control group), or it is a guess
Payback periodUpfront cost / monthly net benefitHides 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:

StepCalculationResult
Tasks that replaced manual work3,996 x 30% (sampled with the customer)1,199
Minutes saved per task18 before, 2 after16
Measured hours saved1,199 x 16 / 60320
Realized hours (80% haircut)320 x 0.80256
Hourly cost$45 wage x 1.43$64.29
Labor value256 x $64.29$16,442
License cost this quarterfrom the contract$6,000
ROI(16,442 - 6,000) / 6,000174%
Payback on a $4,000 onboarding fee4,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

RouteYou controlWatch out for
python-pptxEvery shape, native editable charts, offlineYou place things in inches
Google Slides APIA template deck with tags like {{hours_saved}}Quotas, scopes, public image URLs
Gamma APILittle, a model lays out the slidesNumbers 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:

  1. Code computes every number and stores it as a fact with an id, such as hours_saved.
  2. The model gets ids and labels, and writes {hours_saved} where a number goes.
  3. Code rejects any reply with a digit outside a placeholder, or an unknown id.
  4. Code fills the placeholders with formatted values.
  5. 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

ToolWhat it doesPricing modelNotes
python-pptxCreates and edits .pptx files, with native charts and tablesFree, MIT licenseVersion 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
matplotlibDraws custom charts as imagesFree, PSF-based licenseFor charts PowerPoint cannot make
Google Slides APICopies a template deck and fills it with dataFree within quotasGoogle says charges for going over quota are planned later in 2026
Gamma APIGenerates a deck from text, or from a Gamma templateCredits, on Pro, Ultra, Teams and Business plansText 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 StudioDashboards with scheduled PDF emailFree. Pro is a paid subscriptionLooker Studio is now Data Studio. I did not find the Pro price
GainsightCustomer success platform with health scores and success plansQuote only, Essentials and Enterprise tiersGainsight's pricing page shows the two tiers but no prices. I did not verify current ownership, so check before you cite it
Claude APIWrites the summary through structured outputsPer token. Sonnet 5.5 is $2 input and $10 output per million tokensThe project runs without a key through its fixed fallback text
GitHub ActionsRuns the script on a scheduleFree on public repos. 2,000 minutes a month on the Free plan for private reposStandard 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.

PieceFileJob
Mock datamake_data.pyTwo accounts and 26 weeks of usage
Metricsmetrics.pyEvery number, with ROI and payback
Narrativenarrative.pyModel summary with placeholders, or fixed fallback
Deckdeck.pyBranded 16:9 deck with a native chart
Commandbuild_qbr.pyRuns it all and writes the review checklist
Gatefinalize.pyBlocks a draft that still has placeholders
Optionalgslides.pyGoogle Slides merge
  1. Create a project folder and a virtual environment inside it.
  2. Install the packages with the setup commands below.
  3. Save make_data.py and run it to create accounts.csv and usage.csv.
  4. Save metrics.py and read it. Every assumption, including the load factor and the haircut, sits at the top.
  5. Save narrative.py and test_narrative.py, then run pytest and watch the guardrail tests pass.
  6. Save deck.py and build_qbr.py, then run python build_qbr.py acme 2026Q3.
  7. Open the draft in PowerPoint or Google Slides and check that each slide title states a finding.
  8. Replace the grey placeholder on the last slide with two real recommendations, then save the file under the same name.
  9. Open out/review.md, check each number against the deck, and change each - [ ] to - [x].
  10. Run python finalize.py out/acme-2026Q3-DRAFT.pptx and open the final file it writes.
  11. Set ANTHROPIC_API_KEY, rebuild with --llm, and compare the model's summary with the fallback. Skip this step if you have no key.
  12. Save the GitHub Actions file as .github/workflows/qbr.yml so 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 anthropic

make_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 blindly

test_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?

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?

3. What is the safest way to let an LLM write the executive summary?

4. Which chart title best follows the one-message-per-chart rule?

5. A Forrester TEI study reports a 238% ROI for a vendor's product. What should you take from it?

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

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