Cloud FinOps skill for Codex
Give every cloud dollar an owner and a trend. Codex builds the cost operating model before cutting anything: billing exports in FOCUS format, a small enforced tag policy, budgets with forecast alerts, anomaly routing to owning teams and a unit cost per product. A bundled script measures untagged spend from any billing CSV and can gate a weekly job, and commitment advice is sized from the measured floor with purchases left to you.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cloud-finops Installs to ~/.agents/skills/cloud-finops/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cloud-finops The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cloud-finops Installs to .agents/skills/cloud-finops/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cloud-finops.zipunzip -o cloud-finops.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cloud-finops PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cloud-finops, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up cloud cost management across AWS, Google Cloud and Azure - billing exports in the FOCUS format, a tagging and labeling policy with enforcement, showback by team and environment, budgets and anomaly alerts, unit cost metrics, commitment planning for Savings Plans, reservations and committed use discounts, and Infracost checks on infrastructure PRs. Use when nobody knows who owns the cloud bill, spend jumped unexpectedly, tags are missing, finance asks for cost per customer, or someone proposes buying commitments.
Use it when
- The cloud bill has no clear owners or showback by team
- Spend spiked and you need budgets and anomaly alerts
- Tags or labels are inconsistent across accounts and IaC
- Someone wants to buy Savings Plans, reservations or committed use discounts
Use something else when
- You want concrete AWS resource-level savings such as rightsizing and storage tiers: use aws-cost-optimization
- You are tracking spend on Codex or model usage itself: use codex-cost-control
How to check the result
Run scripts/tag_coverage.py on the latest export to record untagged share, send a test budget alert to the real channel, and reconcile showback totals with the invoice.
The mistake it prevents
Activating AWS cost allocation tags late; they are not retroactive, so months of spend stay unattributable.
Cost work is done when every dollar has an owner, spend surprises are caught within a day instead of at the month-end invoice, teams see a unit cost they can act on, and commitments are bought from measured baselines, not hopes. Visibility comes first; cutting without allocation just moves the argument.
This skill covers the operating model across providers. For hands-on AWS savings (rightsizing, storage classes, NAT and egress fixes) hand off to aws-cost-optimization.
Inspect first
- Read
AGENTS.mdand the IaC (Terraform, Pulumi, CDK, Bicep) to see whether tags or labels are applied centrally (default_tags, stack tags) or per resource. - Find what billing data exists: AWS Data Exports (CUR 2.0 or FOCUS), GCP billing export to BigQuery, Azure Cost Management exports. Most queries need network and billing read permissions; ask the user for an exported CSV if you cannot reach the console.
- Ask for the account or project structure (AWS Organizations OUs, GCP folders, Azure management groups) since it is the cheapest allocation dimension.
- Never purchase commitments, change budgets that trigger automated actions, or delete resources on your own. Those require explicit user approval.
Maturity checklist
- Billing export enabled to a queryable store, ideally in FOCUS columns
- Account/project structure maps to teams or products
- Tag policy written, applied by IaC defaults, enforced in CI and by provider policy
- Untagged spend under 10% of total, tracked weekly
- Budgets per account/team with alerts at 50/80/100% of forecast
- Anomaly detection on, routed to the owning team’s channel
- Monthly showback report per team with trend and top movers
- One unit metric per product (cost per active customer, per 1k requests, per GB processed)
- Commitment coverage and utilization reviewed monthly
- Infracost (or similar) comments cost deltas on infrastructure PRs
Tagging policy
Keep it small. Four required keys beat twelve that nobody fills in.
| Key | Example | Purpose |
|---|---|---|
owner | team-payments | Who gets the alert and the bill |
env | prod, staging, dev | Separate production from waste candidates |
service | checkout-api | Product or component |
cost-center | cc-1042 | Finance mapping |
Rules:
- Lowercase keys and values with hyphens. GCP labels allow only lowercase, digits,
-and_, so a cross-cloud policy should follow GCP’s stricter rules. - Apply tags centrally: Terraform AWS provider
default_tags, Pulumi stack transformations, Bicep parameters passed to every module. - AWS user-defined cost allocation tags must be activated in the Billing console before they appear in cost data, and they are not retroactive. Activate them on day one.
- Some costs can never be tagged (support plans, some data transfer, marketplace fees). Allocate them by a documented rule (proportional to tagged spend) instead of chasing them.
provider "aws" {
region = "us-east-1"
default_tags {
tags = { owner = var.owner, env = var.env, service = var.service, "cost-center" = var.cost_center }
}
}
Enforce in layers: CI lint on IaC (tflint rules, OPA or Checkov policies), AWS Tag
Policies or Azure Policy require tag for creation-time checks, and the coverage report
below for anything that slipped through.
Measuring tag coverage
python3 scripts/tag_coverage.py focus_export.csv --required owner,env,service --max-untagged 10
It reads FOCUS exports (JSON Tags column, EffectiveCost, ServiceName) by default;
for legacy exports map tag columns with --tag-column owner=resource_tags_user_owner
and --cost-col. It prints the untagged share per key and the services contributing most
untagged cost, and exits 1 when the share exceeds --max-untagged, so it can gate a
weekly job.
Budgets and anomalies
| Provider | Budgets | Anomalies |
|---|---|---|
| AWS | AWS Budgets (cost, usage, coverage, utilization), alerts by email or SNS | Cost Anomaly Detection monitors per service, account or tag |
| Google Cloud | Billing budgets with threshold rules and Pub/Sub notifications | Cost anomaly detection in Billing |
| Azure | Cost Management budgets with action groups | Cost Management anomaly alerts |
Budgets alert; they do not cap spend. A hard stop requires automation (for example a Pub/Sub-triggered function that disables billing on a sandbox project), which is destructive and must be scoped to non-production with explicit approval.
Alert on forecasted spend as well as actual so you get warned mid-month.
Unit economics
Pick one metric per product that engineering can influence:
cost per 1k API requests = (allocated service cost for the month) / (requests / 1000)
cost per active customer = (product cost incl. shared allocation) / (monthly active customers)
Report the trend, not the absolute. A rising total with falling unit cost is healthy growth; a flat total with rising unit cost is a regression.
Commitments
| Instrument | Flexibility | Use for |
|---|---|---|
| AWS Compute Savings Plans | Any region, instance family, Fargate, Lambda | Default for steady compute |
| AWS EC2 Instance Savings Plans / RIs | Locked to family and region | Very stable, known fleets |
| AWS RDS / ElastiCache / OpenSearch RIs | Engine and family specific | Databases that will not change soon |
| GCP committed use discounts (resource-based or spend-based) | Region and resource type, or service spend | Steady Compute Engine, Cloud SQL, Cloud Run |
| Azure reservations / Azure savings plan for compute | Specific SKU vs flexible compute | Same split as AWS |
Procedure:
- Measure the baseline: the lowest hourly on-demand spend over the last 30 to 90 days, after rightsizing. Commit to cover the floor, not the average.
- Target roughly 70 to 85% coverage of steady compute; leave the rest on-demand for change.
- Prefer 1-year no-upfront terms unless the workload is certain for 3 years.
- Track utilization monthly; under 95% means you over-committed.
- Present the purchase as a recommendation with numbers. The user or finance buys it. Never execute a purchase.
PR-time cost checks
infracost breakdown --path . --format json --out-file /tmp/base.json
infracost diff --path . --compare-to /tmp/base.json
Infracost needs an API key and network for pricing data. Post the diff as a PR comment so reviewers see ”+$410/month” next to the change that causes it.
Verification
- Coverage script run on the latest export; untagged share and top offenders recorded.
- Budget and anomaly alerts fire to a real channel (send a test notification).
- Showback totals reconcile with the invoice within a small tolerance after allocating untaggable costs.
- Commitment recommendations cite baseline, coverage and break-even.
Output: monthly cost report
## Cloud cost report: <month>
- Total: $X (vs last month +/-Y%, vs budget Z%)
- Untagged share: N% (target < 10%); top untagged: <service, $>
- By team: table of owner, cost, change, top driver
- Unit cost: <metric> = $A (trend)
- Anomalies: <date, service, $ impact, cause, owner, status>
- Commitments: coverage C%, utilization U%, expiring in next 90 days
- Actions: <owner, action, expected monthly saving>
Gotchas
- Comparing unblended cost month to month hides commitment effects; use amortized or
FOCUS
EffectiveCostfor allocation and showback. - Shared clusters (Kubernetes) need in-cluster allocation (OpenCost or Kubecost) by namespace or label; cloud tags stop at the node.
- Data transfer is usually the least visible line and the hardest to tag; break it down by usage type before blaming a team.
- Deleting “idle” resources found in cost data needs owner confirmation; a zero-traffic resource may be a disaster recovery standby.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Cloud FinOps"
short_description: "Give every cloud dollar an owner and a trend"
default_prompt: "$cloud-finops Our AWS and GCP bill doubled and nobody knows which team caused it, set up cost allocation" scripts/tag_coverage.py
#!/usr/bin/env python3
"""Report cost allocation tag coverage from a billing export CSV.
Works with FOCUS exports (a JSON "Tags" column) out of the box, and with exports
that store each tag in its own column via --tag-column key=ColumnName.
Usage:
python3 tag_coverage.py export.csv --required owner,env,cost-center
python3 tag_coverage.py cur.csv --cost-col line_item_unblended_cost \
--group-col line_item_product_code \
--tag-column owner=resource_tags_user_owner --tag-column env=resource_tags_user_env \
--required owner,env --max-untagged 10
Exit codes: 0 ok, 1 untagged share above --max-untagged, 2 bad input.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from collections import defaultdict
from decimal import Decimal, InvalidOperation
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("csv", help="billing export CSV (FOCUS or provider-specific)")
p.add_argument("--required", required=True, help="comma-separated tag keys every cost should carry")
p.add_argument("--cost-col", default="EffectiveCost", help="cost column (default EffectiveCost)")
p.add_argument("--group-col", default="ServiceName", help="column to break untagged cost down by")
p.add_argument("--tags-col", default="Tags", help="column holding a JSON object of tags (FOCUS)")
p.add_argument("--tag-column", action="append", default=[], metavar="KEY=COLUMN",
help="map a tag key to its own column; repeatable")
p.add_argument("--top", type=int, default=10, help="rows to show in the breakdown")
p.add_argument("--max-untagged", type=float, default=None,
help="fail (exit 1) if untagged percentage of cost exceeds this")
return p.parse_args()
def money(raw: str) -> Decimal:
try:
return Decimal(raw.strip() or "0")
except InvalidOperation:
return Decimal("0")
def main() -> int:
a = parse_args()
required = [k.strip().lower() for k in a.required.split(",") if k.strip()]
mapped = {}
for item in a.tag_column:
if "=" not in item:
print(f"error: --tag-column expects KEY=COLUMN, got {item!r}", file=sys.stderr)
return 2
k, col = item.split("=", 1)
mapped[k.strip().lower()] = col.strip()
total = Decimal("0")
untagged = Decimal("0")
missing_by_key: dict[str, Decimal] = defaultdict(Decimal)
untagged_by_group: dict[str, Decimal] = defaultdict(Decimal)
rows = 0
try:
fh = open(a.csv, newline="", encoding="utf-8-sig")
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
with fh:
reader = csv.DictReader(fh)
cols = set(reader.fieldnames or [])
if a.cost_col not in cols:
print(f"error: cost column {a.cost_col!r} not found; columns: {sorted(cols)[:15]}...", file=sys.stderr)
return 2
use_json = not mapped
if use_json and a.tags_col not in cols:
print(f"error: tags column {a.tags_col!r} not found; use --tag-column KEY=COLUMN", file=sys.stderr)
return 2
for row in reader:
rows += 1
cost = money(row.get(a.cost_col, ""))
total += cost
if use_json:
try:
tags = {str(k).lower(): str(v) for k, v in json.loads(row.get(a.tags_col) or "{}").items()}
except (json.JSONDecodeError, AttributeError):
tags = {}
else:
tags = {k: (row.get(col) or "") for k, col in mapped.items()}
missing = [k for k in required if not tags.get(k, "").strip()]
for k in missing:
missing_by_key[k] += cost
if missing:
untagged += cost
untagged_by_group[row.get(a.group_col) or "(none)"] += cost
if rows == 0:
print("error: no data rows", file=sys.stderr)
return 2
pct = float(untagged / total * 100) if total else 0.0
print(f"rows: {rows}")
print(f"total cost: {total:.2f}")
print(f"cost missing at least one required tag: {untagged:.2f} ({pct:.1f}%)")
print("\nmissing cost by tag key:")
for k in required:
share = float(missing_by_key[k] / total * 100) if total else 0.0
print(f" {k:<20} {missing_by_key[k]:>14.2f} {share:5.1f}%")
print(f"\ntop untagged by {a.group_col}:")
for name, cost in sorted(untagged_by_group.items(), key=lambda kv: kv[1], reverse=True)[: a.top]:
print(f" {name[:40]:<40} {cost:>14.2f}")
if a.max_untagged is not None and pct > a.max_untagged:
print(f"\nFAIL: untagged share {pct:.1f}% exceeds {a.max_untagged}%")
return 1
print("\nOK")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.