BXUP Track progress & test yourself — free
DevOps / SRE / Platform · Agentic pattern · ~7 min read
DEVOPS / SRE / PLATFORM · AGENTIC PATTERN

The Thermostat — continuous rightsizing inside a policy envelope, so cost cleanup stops being a quarterly fire drill

A thermostat doesn't ask permission to nudge the temperature two degrees — someone already decided the acceptable range. The Thermostat pattern applies that to capacity and cost: an agent continuously measures utilization, detects drift and waste, and proposes corrections provably inside a pre-approved policy envelope enforced in code.

The rollout is staged — observe, then recommend, then auto only for the reversible — and savings arrive as a stream of small PRs instead of a quarterly purge.

The scenario — before and after

THE QUARTERLY FIRE DRILL

A savings half-life of two quarters

  1. Mid-October

    "The cost project." Finance flags that cloud spend is up 34% year over year, and Dana, the platform engineer who drew the short straw, is handed it.

  2. Week one

    She exports utilization data into a spreadsheet, cross-referencing instance types against a rightsizing report the FinOps tool has been emitting — unread — since March. The report lists 61 recommendations; nobody has actioned any, because each one carries an implicit risk assessment someone would have to do, and nobody owns doing it.

  3. Thursday

    Under quarter-end pressure, the team batches 40 resizes into one change window. Most go fine.

  4. 02:14 Fri

    report-runner — average CPU 16%, which looked like textbook waste — turns out to run the nightly finance batch that spikes to 85% at 02:00. Downsized, it OOM-kills; the finance batch misses its morning deadline, and the retro consumes more engineering hours than the resize saved that month.

  5. Aftermath

    The episode poisons the well: for the next year every rightsizing suggestion is met with "remember report-runner." By Q1 the estate has drifted back — new services launch overprovisioned "to be safe" — and the same 34% conversation is waiting in next October's calendar.

This is the quarterly fire-drill loop: heroic, risky, and with a savings half-life of about two quarters.

THE SAME ESTATE, WITH A THERMOSTAT

Waste skimmed weekly

It runs nightly, reads 14 days of per-service utilization and the current cost rates, and operates inside an envelope the team wrote down once, in a reviewed file: rightsizing only within an instance family, one step at a time; replica changes of at most one; services tagged stateful untouchable; every change must leave 40% headroom above the observed peak, not the average; auto-execute off.

TUESDAY MORNING · THERMOSTAT

two small PRs waiting

2 PRsPROPOSED — about $840 a month and about $250 a montheach is a two-line diff plus the evidence and the policy-check result; three minutes to review
  • catalog-api m5.2xlarge → m5.xlarge across its 6 replicas — 14-day average CPU 12%, peak 22%, projected peak after resize 44%, still under the 60% ceiling the headroom rule implies — about $840 a month
  • image-resizer trimmed — about $250 a month
  • report-runner — proposed nothing, logging "peaks require the headroom; do not downsize"
  • Even if the model had proposed it, the policy check — a plain Python function, not a prompt instruction — would have rejected it: projected peak 170%, ceiling 60%

Merging a PR triggers the normal CI path — the change ships like any other change, because it is one.

The Thermostat saw the same 16% average that fooled the humans — and also the 85% nightly peaks. The trap that caused last year's outage is now caught twice, mechanically.

Six weeks in, the team promotes one action class to auto: replica ±1 scale-downs in the dev environment, because they are reversible with a single command and the worst case is a five-minute blip nobody pays for. Everything else stays at recommend. There is no October fire drill, because there is nothing to purge — waste is skimmed weekly, in increments small enough that no single change can ruin a Friday, and the audit log can show finance exactly which merged PR saved which dollars.

Note

Client-safe by design — the analysis runs against an approved tenancy (Claude on AWS Bedrock, Azure OpenAI) or locally via Ollama, with service names, account IDs, and absolute dollar figures replaced by stable placeholders before prompting while the utilization percentages that drive the decision stay intact. Full setup is in the build guide below.

The flow — where AI plugs in, where value lands

Flow diagram — The Thermostat
Flow diagram — The Thermostat

Blue = AI-added step · Amber = human control point · Gray = your existing tools.

Value at each step

StepWho actsWhat changesWhy it matters
MeasureYour existing toolsMetrics and cost APIs are read on a schedule — nothing new to instrumentThe loop runs on data you already collect; peaks and averages both captured
Detect drift and wasteAgent14-day utilization windows compared against allocation, peaks weighted over averagesThe unread rightsizing report becomes an active process; spiky services are recognized, not flagged
RecommendAgentEach finding lands as a small PR: two-line diff, projected saving, cited evidence, risk noteSavings arrive weekly in 3-minute reviews instead of quarterly in 40-change batches
Policy checkCode you wroteEvery recommendation is validated against the envelope — allowed actions, headroom, forbidden tagsThe model is never trusted to self-police; violations are rejected with printed reasons
ApproveHumanMerge the PR — or close it with a comment the agent's next run can seeThe merge is the approval artifact; normal CI and review culture apply unchanged
ActAgent (inside envelope)The change ships through your existing pipeline; auto-execute only for reversible action classesBlast radius is capped by the envelope, not by hope
VerifyAgent + your dashboardsNext cycle's measurements confirm spend dropped and peaks still fit under the headroom ceilingClosed loop: a resize that hurt latency shows up in the same data that proposed it
AuditYour existing toolsEvery recommendation, rejection reason, merge, and outcome is logged under the agent's identityFinance sees which PR saved what; trust survives personnel changes

The six components in your stack

ComponentIn this lesson's buildIn your stack
Event sourceYou run thermostat.py by hand (a nightly run in spirit)Scheduler/cron, Kubecost alerts, AWS Cost Anomaly Detection, Azure Cost Management alerts
Context stores (read)usage/metrics.json — 14 days of per-service utilizationPrometheus/CloudWatch/Datadog metrics; Kubecost, Cast AI, CloudZero, or cloud cost/billing APIs
Action interface (allowlisted writes)PR-style diff files written to recommendations/Real PRs against your IaC repo (Terraform, Helm values, Kubernetes manifests); later, a narrow rightsize API allowlist
Approval channelYou, reading the diffsPR review with CODEOWNERS on the capacity paths; policy check wired into CI via OPA/Conftest
VerificationRe-run against updated metrics after a changePost-change utilization and SLO dashboards; billing export deltas confirming the projected saving materialized
Audit sink + identityThe printed pass/reject log plus diffs committed to gitAgent runs under its own identity with scoped credentials; recommendations, rejections, and merges retained in git and your audit tooling
Key Takeaway:

Never trust the model to self-police. The envelope lives in a reviewed file, and enforcement is a deterministic policy check in code that runs on every recommendation — the model proposes, the code disposes, the human approves. If your only guardrail is a sentence in the system prompt, you have a wish, not a policy.

Autonomy tier — start here, promote deliberately

The Thermostat is the pattern where staged autonomy must be explicit, because its whole point is eventually acting without a human in the loop — and rushing that step is how you get the 02:14 OOM. Run the ladder on the calendar, not on vibes:

WEEKS 1–2

Observe

The agent runs nightly and only logs what it would recommend. You read the log and count false positives — above all, whether it ever wants to downsize your spiky services. No PRs, no pressure.

WEEKS 3–6

Recommend

Recommendations land as PRs through the policy check. Humans merge every change. Track two numbers: percentage of PRs merged without modification (your precision), and realized savings versus projected (your calibration).

WEEK 7+

Auto — reversible actions only

Promote one narrow action class at a time — start with replica ±1 in non-production — and only where rollback is a single command with instant effect. Instance resizes of stateful anything, cross-environment changes, and anything without a tested one-step rollback stay at recommend indefinitely.

Any one of those triggers drops the affected action class a full tier immediately. Review the tier assignments monthly with the same seriousness as an access review — an autonomy grant is a standing credential.

Warning

Averages lie. A service at 16% average CPU with an 85% nightly peak is not overprovisioned — it is correctly provisioned for its peak, and every naive rightsizer in existence will try to break it. Weight peaks over averages in the analysis, enforce headroom against peaks in the policy check, and treat "the spiky service was left alone" as the first test of any Thermostat you build.

▸Build it yourself — step by step (~60 min, laptop only)optional deep dive — click to expand

You will build a working Thermostat against mock utilization data containing two genuinely overprovisioned services, one healthy service, and one trap — a spiky batch service that looks wasteful on averages. The policy envelope is a YAML file; the enforcement is Python you write. Only external dependency: an Anthropic API key.

Step 0 — setup (5 min).

mkdir thermostat-lab && cd thermostat-lab
python3 -m venv .venv && source .venv/bin/activate
pip install anthropic pydantic pyyaml
export ANTHROPIC_API_KEY=sk-ant-...   # your key
mkdir usage policy recommendations

✅ Checkpoint: python -c "import anthropic, pydantic, yaml; print('ok')" prints ok.

No API key? Run it fully local. Install Ollama (from ollama.com), pull a model, and pip install ollama:

ollama pull llama3.1          # general reasoning; qwen2.5-coder for code-heavy swaps

The lab makes exactly one model call — the client.messages.parse(...) you write below — so only that client swaps; the metric generation and the policy check (the Python function that actually enforces headroom) have no model in them. That call uses a structured Pydantic output, which Ollama supports via its format= argument: pass RecommendationSet.model_json_schema() and validate the returned JSON with the same Pydantic model. Small local models reason less carefully about peaks vs. averages, so keep the policy check strict and keep Anthropic (claude-opus-4-8, adaptive thinking) as the recommended default.

Step 1 — generate the mock metrics (10 min). Create usage/make_metrics.py — a deterministic generator, so your checkpoints match mine:

"""Generates 14 days of per-service utilization. Deterministic on purpose."""
import json
from pathlib import Path

DAYS = [f"2026-07-{d:02d}" for d in range(6, 20)]           # 14 days
WOBBLE = [0, 1, -1, 2, 0, 1, -2, 0, 1, 0, -1, 2, 0, 1]      # day-to-day noise


def days(avg_cpu, peak_cpu, avg_mem, peak_mem):
    return [
        {
            "date": d,
            "cpu_avg_pct": avg_cpu + w,
            "cpu_peak_pct": peak_cpu + w,
            "mem_avg_pct": avg_mem + w,
            "mem_peak_pct": peak_mem + w,
        }
        for d, w in zip(DAYS, WOBBLE)
    ]


services = [
    {   # steady and heavily overprovisioned — the easy win
        "name": "catalog-api", "instance_type": "m5.2xlarge", "replicas": 6,
        "tags": [], "hourly_cost_usd_per_replica": 0.384,
        "daily": days(12, 20, 22, 30),
    },
    {   # also overprovisioned
        "name": "image-resizer", "instance_type": "c5.xlarge", "replicas": 4,
        "tags": [], "hourly_cost_usd_per_replica": 0.17,
        "daily": days(8, 13, 18, 24),
    },
    {   # THE TRAP: low average, hard nightly peak (finance batch at 02:00)
        "name": "report-runner", "instance_type": "m5.xlarge", "replicas": 3,
        "tags": [], "hourly_cost_usd_per_replica": 0.192,
        "daily": days(16, 85, 30, 74),
    },
    {   # healthy utilization — nothing to do here
        "name": "session-store", "instance_type": "r5.large", "replicas": 3,
        "tags": [], "hourly_cost_usd_per_replica": 0.126,
        "daily": days(38, 55, 52, 68),
    },
]

Path("usage/metrics.json").write_text(
    json.dumps({"window_days": 14, "unit": "percent_of_allocated",
                "services": services}, indent=2)
)
print("wrote usage/metrics.json")

Run it from the lab root: python usage/make_metrics.py.

Your stack: this file stands in for a Prometheus/CloudWatch/Datadog query plus a Kubecost, Cast AI, or cloud billing API pull — 14 days is a common recommender lookback, and the per-day peak column is the part naive exports omit.

✅ Checkpoint: usage/metrics.json exists; report-runner shows cpu_avg_pct around 16 but cpu_peak_pct in the mid-80s. That gap is the whole lab.

Step 2 — write the envelope (5 min). Create policy/envelope.yaml:

# The policy envelope. The agent may only act inside this boundary.
# Enforcement lives in thermostat.py's policy check — in code, not in the prompt.
allowed_actions:
  - rightsize_within_family_one_step   # m5.2xlarge -> m5.xlarge OK; m5 -> c6g never
  - scale_replicas_plus_minus_one
forbidden_tags:
  - stateful            # services carrying this tag are untouchable
min_headroom_pct: 40    # after any change, projected PEAK utilization must stay
                        # below (100 - min_headroom_pct) percent
auto_execute: false     # recommend tier: a human merges every change

This file is the pattern's central artifact. It gets a code review when it changes, and it is what a client or an auditor asks to see.

Step 3 — the Thermostat itself (15 min). Create thermostat.py:

"""The Thermostat — closed-loop rightsizing inside a policy envelope."""
import json
from pathlib import Path

import anthropic
import yaml
from pydantic import BaseModel

VCPUS = {"large": 2, "xlarge": 4, "2xlarge": 8, "4xlarge": 16}
LADDER = ["large", "xlarge", "2xlarge", "4xlarge"]


class ResourceState(BaseModel):
    instance_type: str
    replicas: int


class Recommendation(BaseModel):
    service: str
    current: ResourceState
    proposed: ResourceState
    projected_saving_usd: float
    risk_note: str
    evidence: str


class RecommendationSet(BaseModel):
    recommendations: list[Recommendation]


SYSTEM = """You are a capacity analyst. Given 14 days of utilization data and a
policy envelope, propose rightsizing actions ONLY where the data supports them.

Rules:
- Peaks matter more than averages. A service with a low average but high daily
  peaks needs its headroom: explain that in a risk_note and propose nothing, or
  explicitly propose nothing for it.
- At most one action per service. Prefer one-step instance rightsizing within
  the same family. Only propose actions the envelope allows.
- evidence must cite actual numbers from the data (avg, peak, window length).
- projected_saving_usd is monthly, derived from the hourly cost figures.
- If nothing should change for a service, omit it entirely.
Your output is a proposal. A separate policy check in code validates every
recommendation — do not assume you are the last line of defense."""


def split_type(itype: str):
    family, _, size = itype.partition(".")
    return family, size


def capacity_factor(cur: ResourceState, prop: ResourceState) -> float:
    _, cs = split_type(cur.instance_type)
    _, ps = split_type(prop.instance_type)
    return (VCPUS[ps] / VCPUS[cs]) * (prop.replicas / cur.replicas)


def policy_check(rec: Recommendation, svc: dict, envelope: dict) -> list[str]:
    """Return a list of violations. Empty list means the recommendation passes."""
    violations = []
    if any(t in envelope.get("forbidden_tags", []) for t in svc["tags"]):
        violations.append(
            f"service is tagged {svc['tags']} — forbidden by envelope")
    cf, cs = split_type(rec.current.instance_type)
    pf, ps = split_type(rec.proposed.instance_type)
    if cf != pf:
        violations.append(f"cross-family move {cf} -> {pf} is forbidden")
    elif cs != ps and abs(LADDER.index(cs) - LADDER.index(ps)) != 1:
        violations.append(f"{cs} -> {ps} is more than one step within the family")
    if abs(rec.proposed.replicas - rec.current.replicas) > 1:
        violations.append("replica change larger than +/-1")
    if (rec.current.instance_type != svc["instance_type"]
            or rec.current.replicas != svc["replicas"]):
        violations.append(
            "'current' does not match the metrics file — hallucinated state")
    observed_peak = max(d["cpu_peak_pct"] for d in svc["daily"])
    projected_peak = observed_peak / capacity_factor(rec.current, rec.proposed)
    limit = 100 - envelope["min_headroom_pct"]
    if projected_peak > limit:
        violations.append(
            f"headroom: observed peak {observed_peak}% -> projected "
            f"{projected_peak:.0f}% exceeds limit {limit}% "
            f"(min_headroom_pct={envelope['min_headroom_pct']})")
    return violations


def write_diff(rec: Recommendation, note: str) -> None:
    diff = f"""--- a/deploy/{rec.service}.yaml
+++ b/deploy/{rec.service}.yaml
@@ resources @@
-  instance_type: {rec.current.instance_type}
-  replicas: {rec.current.replicas}
+  instance_type: {rec.proposed.instance_type}
+  replicas: {rec.proposed.replicas}

# projected saving : ${rec.projected_saving_usd:,.0f}/month (model estimate — verify against billing)
# evidence         : {rec.evidence}
# risk note        : {rec.risk_note}
# policy check     : PASSED — {note}
"""
    Path(f"recommendations/{rec.service}.diff").write_text(diff)


def main() -> None:
    metrics = json.loads(Path("usage/metrics.json").read_text())
    envelope = yaml.safe_load(Path("policy/envelope.yaml").read_text())
    by_name = {s["name"]: s for s in metrics["services"]}

    client = anthropic.Anthropic()
    response = client.messages.parse(
        model="claude-opus-4-8",
        max_tokens=4096,
        thinking={"type": "adaptive"},
        system=SYSTEM,
        messages=[{
            "role": "user",
            "content": (
                "POLICY ENVELOPE:\n" + Path("policy/envelope.yaml").read_text()
                + "\n\nUTILIZATION DATA:\n" + json.dumps(metrics, indent=2)
            ),
        }],
        output_format=RecommendationSet,
    )

    for rec in response.parsed_output.recommendations:
        svc = by_name.get(rec.service)
        if svc is None:
            print(f"REJECT {rec.service}: unknown service (not in metrics)")
            continue
        violations = policy_check(rec, svc, envelope)
        if violations:
            print(f"REJECT {rec.service}: " + "; ".join(violations))
            continue
        peak = max(d["cpu_peak_pct"] for d in svc["daily"])
        projected = peak / capacity_factor(rec.current, rec.proposed)
        note = (f"peak {peak}% -> projected {projected:.0f}% "
                f"<= {100 - envelope['min_headroom_pct']}%")
        write_diff(rec, note)
        print(f"PASS   {rec.service}: {rec.current.instance_type} x{rec.current.replicas}"
              f" -> {rec.proposed.instance_type} x{rec.proposed.replicas}"
              f" (~${rec.projected_saving_usd:,.0f}/mo) — {note}")


if __name__ == "__main__":
    main()

Read policy_check before you run anything — it is the point of the lab. The model can propose whatever it likes; only recommendations that survive this deterministic function become diffs. Note it also rejects hallucinated state: if the model's current disagrees with the metrics file, the recommendation dies regardless of how good it sounds.

Step 4 — run it (5 min).

python thermostat.py

✅ Checkpoint: you should see PASS lines for catalog-api (m5.2xlarge → m5.xlarge, roughly $840/month) and image-resizer (c5.xlarge → c5.large, roughly $250/month), and nothing proposed for report-runner — or, if the model mentions it, a proposal-free risk note. That spiky service is the key test: a good run either skips it or explicitly says the peaks need their headroom. session-store should also get nothing (it is healthily utilized). Check recommendations/ — one .diff per passing service, each a reviewable two-line change with evidence and the policy note attached.

If the model does propose downsizing report-runner, watch what happens: the policy check rejects it with the headroom math printed (peak 87% → projected ~174% against a 60% limit). That is the two-layer defense working — but treat a model that proposed it as a prompt to fix, not just a rejection to shrug at.

Step 5 — end-to-end test: close the loop (5 min). Simulate merging the catalog-api PR: edit usage/make_metrics.py, change catalog-api to "instance_type": "m5.xlarge" and its utilization line to days(24, 42, 44, 58) (half the capacity, so utilization roughly doubles). Regenerate and re-run:

python usage/make_metrics.py && python thermostat.py

✅ Checkpoint: no new recommendation for catalog-api — a further step to m5.large would project peak ~88%, far over the 60% ceiling. The loop is closed: the same measurement that proposed the change now verifies the change landed correctly and stops the ratchet from over-tightening. Restore the original values and regenerate before continuing.

Step 6 — red-team exercise A: tighten the envelope (7 min). Edit policy/envelope.yaml, set min_headroom_pct: 60, and re-run python thermostat.py.

✅ Checkpoint: the catalog-api recommendation must now be rejected by your code with the printed reason — observed peak 22% → projected 44%, which exceeds the new 40% limit — while image-resizer (projected ~30%) still passes. The important observation: you changed policy in a YAML file and behavior changed without touching the model or the prompt. That is what "the envelope is code, not vibes" means, and it is exactly what an auditor wants to see. Restore min_headroom_pct: 40.

Step 7 — red-team exercise B: the stateful tag (5 min). In usage/make_metrics.py, change image-resizer's tags to "tags": ["stateful"], regenerate, re-run.

✅ Checkpoint: any recommendation for image-resizer is blocked at the policy check with the forbidden-tag reason — even if the model, seeing 8% utilization, proposes it anyway (it usually will, since the data screams waste; the envelope should be visible in the prompt, but you must not depend on the model honoring it). One tag in the source of truth is now a hard fence. Restore the empty tag list and regenerate.

Step 8 — closing artifact (5 min). Write staged-rollout-memo.md: your observe/recommend/auto calendar (weeks 1–2, 3–6, 7+), which single action class you would promote to auto first and why it is reversible in one command, your three demotion triggers, and the two metrics you will track during the recommend phase (merge-without-modification rate, realized-vs-projected savings). This memo is the deliverable a platform lead or client actually signs off on — the code is just its supporting evidence.

Client-safe variant: cost and utilization data is often contractually sensitive in client accounts. Run the identical pipeline against an approved tenancy — Claude on AWS Bedrock inside the client's own account boundary, or a client-approved Azure OpenAI deployment (adjust the SDK call), or locally via Ollama for drills — and redact before prompting: replace service names, account IDs, and absolute dollar figures with stable placeholders (catalog-api → SVC-A, costs normalized to an index), keeping the utilization percentages that drive the analysis intact.

Pro Tip

Wire the policy check into CI as its own step (OPA/Conftest are built for exactly this), so even a hand-written capacity PR gets the same headroom-against-peaks validation the agent's PRs get. The envelope stops being an agent guardrail and becomes team policy — which is what it always should have been.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in