What This Agent Does
A Terraform cost review agent runs on every pull request that touches infrastructure, computes the monthly cost delta between the base branch and the PR with Infracost, applies budget gates that are plain numbers in a YAML file, and asks an LLM to do the one thing Infracost cannot: explain in three sentences what is driving the delta, name the cheaper equivalent if one exists, and say out loud which parts of the estimate are blind because nobody wrote a usage file. It posts one comment, sets one status check, and never runs terraform apply.
It is the cost twin of the Terraform plan review agent. That agent asks "is this change dangerous?" This one asks "is this change expensive, and did the author know?" Both questions need answering before merge, and the second one is the one that usually gets skipped, because a reviewer can see a delete in a plan but cannot see that db.r6g.4xlarge with 20,000 provisioned IOPS is a five-figure monthly decision.
Why the Infracost Comment Alone Is Not Enough
The Infracost GitHub Action already posts a cost diff table on PRs. Three things go wrong in practice when that table is the whole process.
Nobody owns the number. A comment saying +$1,842/month with no threshold attached is information, not a gate. Teams scroll past it the same way they scroll past a 400-line plan.
Usage-based resources show as zero. Infracost prices what it can see in the plan. S3 storage, Lambda invocations, NAT Gateway data processing, DynamoDB on-demand reads, and CloudWatch log ingestion all depend on usage Infracost cannot know, so without a usage file they appear as $0.00 or Monthly cost depends on usage. A PR that adds a NAT Gateway in a new VPC shows the hourly charge and hides the data-processing charge that the network cost agent later finds to be the real bill.
The table does not explain itself. aws_rds_cluster_instance.analytics[2] going from $0 to $1,120 is a line item. Whether that is a new replica someone forgot to put in the ticket, or the deliberate scale-up the ticket asked for, is a sentence a human has to write, and this is the one place an LLM earns its tokens.
Step 1: Produce the Diff JSON in CI
Infracost needs two snapshots: the base branch and the PR head. The base breakdown is cheap to cache per commit SHA so the PR job only prices the head.
# .github/workflows/tf-cost-review.yml
name: terraform-cost-review
on:
pull_request:
paths: ["infra/**"]
permissions:
contents: read
pull-requests: write
statuses: write
jobs:
cost:
runs-on: ubuntu-latest
steps:
- uses: infracost/actions/setup@v3
with:
api-key: ${{ secrets.INFRACOST_API_KEY }}
- name: Checkout base
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- name: Price base branch
run: |
infracost breakdown --path=base/infra \
--usage-file=base/infra/infracost-usage.yml \
--format=json --out-file=/tmp/base.json
- name: Checkout PR
uses: actions/checkout@v4
with:
path: head
- name: Diff against base
run: |
infracost diff --path=head/infra \
--usage-file=head/infra/infracost-usage.yml \
--compare-to=/tmp/base.json \
--format=json --out-file=/tmp/diff.json
- name: Cost review agent
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python head/scripts/tf_cost_review.py /tmp/diff.json head/infra/cost-policy.yml
The job has no cloud credentials. Infracost prices from the Terraform source plus its pricing API, so the only secret in play is the Infracost key, and the agent itself can read a JSON file and write a PR comment. That is the entire permission surface, and it should stay that way.
The fields the agent needs out of diff.json are stable: diffTotalMonthlyCost at the top level, then per project a diff.resources array where each resource carries name, monthlyCost, and costComponents with name, unit, monthlyQuantity, price, and monthlyCost. The summary.unsupportedResourceCounts map tells you what Infracost silently skipped, which matters more than most teams realize.
Step 2: Budget Gates Are Numbers in a File, Not Judgment Calls
The gate thresholds live next to the Terraform code so they go through the same code review as everything else.
# infra/cost-policy.yml
environments:
prod:
path_prefix: "infra/prod/"
warn_monthly_delta: 200
block_monthly_delta: 1000
block_percent_delta: 15
approver_team: finops
staging:
path_prefix: "infra/staging/"
warn_monthly_delta: 100
block_monthly_delta: 400
block_percent_delta: 30
approver_team: platform
hard_rules:
- id: nat-gateway-without-usage
resource_type: aws_nat_gateway
require_usage_keys: ["monthly_data_processed_gb"]
- id: previous-gen-instance
resource_type: aws_instance
deny_instance_prefixes: ["m4.", "c4.", "r4.", "t2."]
- id: gp2-volume
resource_type: aws_ebs_volume
deny_attr: {"type": "gp2"}
The Python gate reads the diff and the policy and produces findings before any model is involved.
# scripts/tf_cost_review.py (gate section)
import json, sys, yaml
def load(diff_path, policy_path):
return json.load(open(diff_path)), yaml.safe_load(open(policy_path))
def env_for(project_path, policy):
for name, env in policy["environments"].items():
if project_path.startswith(env["path_prefix"]):
return name, env
return "unknown", {"warn_monthly_delta": 0, "block_monthly_delta": 0,
"block_percent_delta": 0, "approver_team": "platform"}
def gate(diff, policy):
findings = []
for proj in diff["projects"]:
env_name, env = env_for(proj["metadata"]["path"], policy)
delta = float(proj["diff"]["totalMonthlyCost"] or 0)
past = float(proj["pastBreakdown"]["totalMonthlyCost"] or 0)
pct = (delta / past * 100) if past > 0 else (100.0 if delta > 0 else 0.0)
level = "ok"
if delta >= env["block_monthly_delta"] or pct >= env["block_percent_delta"]:
level = "block"
elif delta >= env["warn_monthly_delta"]:
level = "warn"
findings.append({"kind": "budget", "env": env_name, "level": level,
"delta": round(delta, 2), "percent": round(pct, 1),
"approver": env["approver_team"]})
unsupported = proj.get("summary", {}).get("unsupportedResourceCounts", {})
if unsupported:
findings.append({"kind": "unsupported", "env": env_name, "level": "warn",
"resources": unsupported})
for r in proj["diff"]["resources"]:
for comp in r.get("costComponents", []):
if comp.get("monthlyQuantity") is None and comp.get("price"):
findings.append({"kind": "usage_blind", "env": env_name,
"level": "warn", "address": r["name"],
"component": comp["name"], "unit": comp["unit"]})
return findings
The usage_blind finding is the one worth the whole exercise. A cost component with a price but no monthlyQuantity means Infracost knows the unit rate and has no idea how many units you will consume. Every one of those is a line on the bill that the PR comment is currently showing as zero.
Percent thresholds matter as much as absolute ones. A +$300/month change in a prod project that costs $40,000/month is noise. The same +$300/month in a staging project that costs $600/month is a 50% increase and almost always a mistake, usually an instance size copied from prod.
Step 3: The Model Explains, It Does Not Price
The model receives the resource-level diff, the gate findings, and the usage file. It returns a strict schema. The system prompt forbids it from inventing dollar figures: every number it cites must be a monthlyCost or price that appears in the input, and code checks that afterward.
import anthropic, re
EXPLAIN_TOOL = {
"name": "explain_cost_delta",
"description": "Explain a Terraform cost diff for a PR reviewer.",
"input_schema": {
"type": "object",
"properties": {
"summary": {"type": "string",
"description": "Max 3 sentences. What changed and why it costs what it costs."},
"top_drivers": {"type": "array", "maxItems": 3, "items": {
"type": "object",
"properties": {"address": {"type": "string"},
"monthly_cost": {"type": "number"},
"reason": {"type": "string"}},
"required": ["address", "monthly_cost", "reason"]}},
"cheaper_alternatives": {"type": "array", "items": {
"type": "object",
"properties": {"address": {"type": "string"},
"suggestion": {"type": "string"},
"tradeoff": {"type": "string"}},
"required": ["address", "suggestion", "tradeoff"]}},
"usage_assumptions_needed": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "string", "enum": ["high", "medium", "low"]},
},
"required": ["summary", "top_drivers", "cheaper_alternatives",
"usage_assumptions_needed", "confidence"],
},
}
SYSTEM = (
"You are a FinOps reviewer reading an Infracost diff for one Terraform PR. "
"Explain the cost delta to the engineer who opened the PR. Rules: every dollar "
"figure you mention MUST be copied from a monthlyCost or price field in the input; "
"never estimate a price yourself. Suggest a cheaper alternative only when it is a "
"well-known same-service substitution (newer instance generation, gp3 over gp2, "
"Graviton, smaller size when the diff shows an oversize jump, Intelligent-Tiering). "
"Do not suggest architectural rewrites. If a cost component has no quantity, list "
"the usage key the author must add instead of guessing the cost. Infracost shows "
"list prices, so note that Savings Plans and Reserved Instances are not reflected. "
"Call explain_cost_delta exactly once."
)
def explain(diff, findings, usage_yaml):
client = anthropic.Anthropic()
payload = {"diff_resources": [
{"address": r["name"], "monthlyCost": r.get("monthlyCost"),
"components": r.get("costComponents", [])}
for p in diff["projects"] for r in p["diff"]["resources"]],
"gate_findings": findings, "usage_file": usage_yaml}
msg = client.messages.create(
model="claude-sonnet-5-5", max_tokens=1200, system=SYSTEM,
tools=[EXPLAIN_TOOL],
tool_choice={"type": "tool", "name": "explain_cost_delta"},
messages=[{"role": "user", "content": json.dumps(payload)[:80000]}])
out = next(b.input for b in msg.content if b.type == "tool_use")
return validate_numbers(out, payload)
def validate_numbers(out, payload):
allowed = set()
for r in payload["diff_resources"]:
if r["monthlyCost"]:
allowed.add(round(float(r["monthlyCost"]), 2))
for c in r["components"]:
for k in ("monthlyCost", "price"):
if c.get(k):
allowed.add(round(float(c[k]), 2))
for d in out["top_drivers"]:
if round(float(d["monthly_cost"]), 2) not in allowed:
d["monthly_cost"] = None
d["reason"] += " (figure removed: not present in Infracost output)"
for text in [out["summary"]] + [a["suggestion"] for a in out["cheaper_alternatives"]]:
for m in re.findall(r"\$([\d,]+\.?\d*)", text):
if round(float(m.replace(",", "")), 2) not in allowed:
out["confidence"] = "low"
return out
The validator is not decoration. In testing, the most common model failure was not a wrong explanation but a helpful one: "switching to r6g.large would cost roughly $95/month." That figure was plausible and not in the input, which is exactly the kind of number a reviewer repeats in a planning meeting. Strip it, and downgrade confidence, so the comment shows the reader that the model reached beyond its data.
Step 4: One Comment, One Status, One Label
import os, requests
def publish(findings, explanation, head_sha, pr):
gh = {"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"Accept": "application/vnd.github+json"}
repo = os.environ["GITHUB_REPOSITORY"]
budget = [f for f in findings if f["kind"] == "budget"]
worst = max((f["level"] for f in budget), key=["ok", "warn", "block"].index)
blind = [f for f in findings if f["kind"] == "usage_blind"]
lines = [f"## Terraform cost review ({worst.upper()})", "", explanation["summary"], ""]
for b in budget:
lines.append(f"- **{b['env']}**: {b['delta']:+.2f} USD/month ({b['percent']:+.1f}%)"
f" -> {b['level']}, approver: @{repo.split('/')[0]}/{b['approver']}")
if explanation["cheaper_alternatives"]:
lines += ["", "**Cheaper equivalents to consider**"]
lines += [f"- `{a['address']}`: {a['suggestion']} ({a['tradeoff']})"
for a in explanation["cheaper_alternatives"]]
if blind:
lines += ["", f"**{len(blind)} cost component(s) priced at zero because no usage was given**"]
lines += [f"- `{f['address']}` / {f['component']} ({f['unit']})" for f in blind[:8]]
lines += ["", "Add the keys to `infracost-usage.yml`: " +
", ".join(explanation["usage_assumptions_needed"])]
lines += ["", f"_Model confidence: {explanation['confidence']}. List prices only; "
"Savings Plans and RI discounts are not reflected._"]
requests.post(f"https://api.github.com/repos/{repo}/issues/{pr}/comments",
headers=gh, json={"body": "\n".join(lines)}).raise_for_status()
requests.post(f"https://api.github.com/repos/{repo}/statuses/{head_sha}", headers=gh,
json={"state": "failure" if worst == "block" else "success",
"context": "terraform/cost-review",
"description": f"cost gate: {worst}"}).raise_for_status()
if worst == "block":
requests.post(f"https://api.github.com/repos/{repo}/issues/{pr}/labels",
headers=gh, json={"labels": ["finops-approval-needed"]}).raise_for_status()
A block is not a veto. It is a required status check plus a label that routes the PR to the approver team named in the policy, which is the same approval-gate pattern used for agents that act on production. A FinOps reviewer can approve a +$3,000/month change in thirty seconds when the comment already explains that it is the planned read replica from the capacity ticket. What they cannot do is approve it when it never reached them.
Step 5: Fix the Usage File, Because It Is Where the Accuracy Lives
The honest limit of this whole design is that Infracost is only as accurate as infracost-usage.yml. Two habits close most of the gap.
First, seed the file from reality instead of guessing. Infracost can pull actual usage from CloudWatch for supported resources when run with cloud credentials outside CI:
cd infra/prod
infracost breakdown --path . --sync-usage-file --usage-file infracost-usage.yml
Run that from a workstation or a scheduled job with read-only CloudWatch access, commit the result, and the usage file stops being fiction. Keep the sync out of the PR job itself so the PR job keeps its zero-credential posture.
Second, treat usage_blind findings as PR feedback to the author, not as agent noise. When a PR adds a NAT Gateway and the comment says "add monthly_data_processed_gb for aws_nat_gateway.private[0]", the author writes a number they can defend, and the next diff prices it. This is the point where the Terraform cost patterns stop being a blog post and become a review step.
What the Agent Deliberately Cannot See
Three limits are worth stating on the comment itself, which is why the footer is hard-coded rather than left to the model.
Commitments. Infracost prices on-demand list rates. If 70% of your compute is covered by Savings Plans, a +$1,000/month EC2 delta may cost closer to $400 in real money, or may push you over your commitment and cost the full amount. The Savings Plans coverage agent answers that question; this agent should link to its latest report rather than guess.
Unsupported resources. Every entry in unsupportedResourceCounts is a resource that costs something and shows as nothing. Surface the count every time. A diff that adds twelve unsupported resources and shows +$0.00 is not free.
Change risk. A cheap change can still be a dangerous one. Keep the cost agent and the deployment risk scoring agent as separate status checks with separate owners, because the engineer who can approve a budget overrun is rarely the engineer who can approve a security-group change, and merging the two into one score hides which one is red.
Running Cost and Rollout
The model call is one request per PR with a payload capped at 80 KB, so even an infrastructure monorepo with forty PRs a day lands well inside the per-PR reviewer budget discussed in the token economics post. Infracost itself is the slow step, typically under a minute per project.
Roll it out in comment-only mode first: set every block_monthly_delta to an absurd number, let the comments run for two weeks, and read them. The thresholds you then write into cost-policy.yml will come from the distribution of real deltas your team ships, not from a number picked in a meeting. Then lower them, one environment at a time, starting with staging where a false block costs an engineer ten minutes rather than a release.