UTM Attribution skill for Codex
One lowercase convention, zero Unassigned traffic. Codex writes a UTM convention whose medium values land in the right GA4 default channel, sets dynamic parameters in Google Ads, Microsoft Ads and Meta, and lints link lists offline for missing parameters, uppercase, fragments, internal UTMs and campaign spelling drift. For attribution it compares first, last and linear credit in SQL and states what click-based models cannot see.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- utm-attribution Installs to ~/.agents/skills/utm-attribution/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install utm-attribution 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 utm-attribution Installs to .agents/skills/utm-attribution/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/utm-attribution.zipunzip -o utm-attribution.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))) utm-attribution 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 $utm-attribution, 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
Defines and enforces UTM tagging conventions (utm_source, utm_medium, utm_campaign, utm_content, utm_term, utm_id) that map cleanly onto GA4 default channel groups, builds and lints tagged links in bulk, sets dynamic parameters in Google Ads, Meta and Microsoft Ads, and analyses attribution - first touch, last touch, linear and data-driven - from GA4, PostHog or warehouse data. Use when asked to create a UTM naming convention, fix "Unassigned" or "(other)" traffic in GA4, tag a campaign's links, or compare which channels really drive signups.
Use it when
- Campaign links are tagged inconsistently or by hand
- GA4 shows Unassigned, (not set) or split rows for the same source
- You need dynamic UTMs configured in ad platforms
- Stakeholders disagree about which channel drives conversions
Use something else when
- You need GA4 events, key events or consent mode configured: use ga4-analytics
- You are designing the full product event taxonomy: use analytics-tracking-plan
How to check the result
The lint script exits 0 for every link list, and a test click per channel appears in GA4 Realtime with the expected source, medium and default channel group.
The mistake it prevents
Adding UTMs to internal links and banners, which overwrites the real acquisition source for the rest of the session.
Tracking is done well when every external link a team controls is tagged from one lowercase convention, internal links carry no UTMs, GA4 sorts the traffic into the right default channel, and attribution answers state which model they use and what it cannot see.
Inspect first
- Read
AGENTS.md, then look for existing conventions and link builders:rg -n "utm_(source|medium|campaign)" -g '!node_modules' | head -50. - Pull the current reality from analytics: GA4 Traffic acquisition report by
“Session source / medium” and “Session campaign” for the last 90 days. List variant
spellings (
Facebook,facebook.com,fb) and the share of Unassigned traffic. - Find where links come from: ad platforms, ESP, social scheduler, partner programs, QR codes, sales email signatures. Each is a source of inconsistency.
The convention
| Parameter | Meaning | Rule | Examples |
|---|---|---|---|
utm_source | Who sent the traffic (site, platform, publication) | Lowercase platform or publisher name, no TLD | google, linkedin, newsletter, partner-acme |
utm_medium | Marketing channel type | Fixed allowed list only (below) | cpc, paid_social, email |
utm_campaign | The initiative | <yyyy>-<qN or mm>-<slug> or a stable slug | 2026-q4-launch, spring-sale |
utm_content | Which creative or link within the campaign | Variant identifier | video-a, header-cta |
utm_term | Paid keyword or audience | Usually filled by dynamic params | {keyword} |
utm_id | Campaign ID for cost data import | Platform or internal ID | cmp_10443 |
Allowed utm_medium values, chosen to land in GA4 default channel groups:
utm_medium | GA4 default channel (with a matching source) |
|---|---|
cpc | Paid Search (source is a search engine) or Paid Social (source is a social site) |
paid_social | Paid Social (any value starting with paid counts as paid) |
display | Display |
email | |
social | Organic Social |
affiliate | Affiliates |
referral | Referral |
sms | SMS |
audio, video | Audio / Paid or Organic Video depending on source |
Rules:
- Lowercase everything, hyphens inside values, no spaces. GA4 values are case-sensitive in
reports:
Emailandemailbecome two rows. - Never put UTMs on internal links (nav, in-app banners, links between your own
subdomains). They overwrite the real source for the session. Use an event parameter
such as
link_locationinstead. - UTMs go in the query string before any
#fragment; hash-router apps must still put them before#. - Keep a source of truth: a shared sheet or a
utm-conventions.mdplus the link builder; update both together.
Building links
Prefer a generator over hand-typing. Minimal builder:
export function tag(url: string, p: { source: string; medium: string; campaign: string; content?: string; term?: string }) {
const u = new URL(url);
const clean = (s: string) => s.trim().toLowerCase().replace(/[\s_]+/g, "-");
u.searchParams.set("utm_source", clean(p.source));
u.searchParams.set("utm_medium", p.medium); // from the allowed list, unmodified
u.searchParams.set("utm_campaign", clean(p.campaign));
if (p.content) u.searchParams.set("utm_content", clean(p.content));
if (p.term) u.searchParams.set("utm_term", p.term);
return u.toString();
}
Dynamic parameters in ad platforms (set once at account or campaign level):
| Platform | Where | Example |
|---|---|---|
| Google Ads | Auto-tagging (gclid) on; optional final URL suffix | utm_source=google&utm_medium=cpc&utm_campaign={_campaign}&utm_term={keyword}&utm_id={campaignid} with a custom {_campaign} parameter |
| Microsoft Ads | Auto-tagging setting can append UTMs; msclkid | Check the account setting before adding manual UTMs to avoid duplicates |
| Meta | Ad level “URL parameters” | utm_source=facebook&utm_medium=paid_social&utm_campaign={{campaign.name}}&utm_content={{ad.name}}&utm_term={{adset.name}} |
With Google Ads auto-tagging and GA4 linked, GA4 attributes Google Ads clicks from
gclid; keep UTMs consistent anyway for other tools.
Linting links in bulk
scripts/utm_lint.py (standard library, offline) checks a list or CSV of URLs for missing
required parameters, uppercase or spaces, mediums outside the allowed list, UTMs after
#, duplicated parameters, UTMs on internal domains, and campaigns spelled several ways.
python3 scripts/utm_lint.py links.txt
python3 scripts/utm_lint.py campaign-links.csv --column url --internal example.com,app.example.com
python3 scripts/utm_lint.py links.txt --mediums cpc,paid_social,email,social,affiliate,referral
Exit code 1 means problems; fix and rerun until clean before links go into ads or emails.
Fixing messy historical data
- Do not rewrite raw data. Create a GA4 custom channel group that normalises variants
(e.g. source matches regex
^(facebook|fb|facebook\.com|m\.facebook\.com)$). Custom channel groups apply retroactively in reports. - In the warehouse, build a mapping table (
raw_source, raw_medium -> source, medium, channel) and join it in a view. - For “(not set)” and Unassigned: check for UTMs on redirects that strip query strings, links in apps that open without a referrer, consent mode denying analytics storage, and mediums outside the allowed list.
Attribution analysis
| Question | Model / source |
|---|---|
| Which channel introduces new users? | First touch: GA4 “First user default channel group”, or first session per user |
| Which channel closes? | Last non-direct touch: GA4 session-scoped reports |
| Credit across the journey for paid optimisation | GA4 data-driven attribution in Advertising reports (the default model) |
| Does a channel cause conversions at all? | Incrementality: geo holdouts, conversion lift studies, or pausing a channel |
| B2B with long cycles | Self-reported attribution (“How did you hear about us?”) alongside clicks |
GA4 offers data-driven and last-click models (first click, linear, time decay and position-based were removed in 2023). For other models, use the warehouse.
Warehouse-agnostic example over a touches table (one row per tagged session per user)
and a conversions table:
WITH paths AS (
SELECT c.user_id, c.converted_at, t.channel, t.touched_at,
ROW_NUMBER() OVER (PARTITION BY c.user_id, c.converted_at ORDER BY t.touched_at) AS n_first,
ROW_NUMBER() OVER (PARTITION BY c.user_id, c.converted_at ORDER BY t.touched_at DESC) AS n_last,
COUNT(*) OVER (PARTITION BY c.user_id, c.converted_at) AS n_touches
FROM conversions c
JOIN touches t
ON t.user_id = c.user_id
AND t.touched_at <= c.converted_at
AND t.touched_at >= c.converted_at - INTERVAL '30' DAY -- lookback window
)
SELECT channel,
SUM(CASE WHEN n_first = 1 THEN 1 ELSE 0 END) AS first_touch,
SUM(CASE WHEN n_last = 1 THEN 1 ELSE 0 END) AS last_touch,
ROUND(SUM(1.0 / n_touches), 1) AS linear
FROM paths
GROUP BY channel
ORDER BY last_touch DESC;
Adjust interval syntax for the engine (BigQuery: TIMESTAMP_SUB(c.converted_at, INTERVAL 30 DAY)).
Interpret honestly:
- Click-based attribution cannot see dark social, podcasts, word of mouth, or view-through impressions; brand search often gets credit that belongs to them.
- Platforms (Meta, Google Ads) report their own attributed conversions with their own windows; totals across platforms exceed actual conversions. Do not sum them.
- Consent banners and ad blockers remove a share of users entirely; state the coverage.
Gotchas
- Link shorteners or redirects dropping the query string. Test the final landing URL.
- Social schedulers appending their own UTMs on top of yours; check one published link.
- Partner links with
utm_source=partnerfor every partner; use a per-partner value. - Email clients’ click tracking wraps links; confirm UTMs survive the redirect.
- Changing the convention mid-quarter without a mapping breaks trend reports.
Verification
- Lint script exits 0 on every link list before launch.
- Click one live link per channel and confirm GA4 DebugView/Realtime shows the expected source, medium and campaign, and the right default channel group.
- Unassigned share in GA4 drops after the fix; record before/after.
Output template
## UTM convention v<N> (owner: <team>)
| Parameter | Rule | Allowed values / pattern |
**Platform settings:** Google Ads suffix, Meta URL parameters, ESP defaults
**Lint result:** <file>: clean
**Attribution readout:** model, lookback, coverage caveats, table by channel 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: "UTM Attribution"
short_description: "One lowercase convention, zero Unassigned traffic"
default_prompt: "$utm-attribution Create a UTM naming convention for our team and check our current campaign links against it" scripts/utm_lint.py
#!/usr/bin/env python3
"""Lint tagged URLs against a UTM convention. Standard library only, works offline.
Reads URLs from a text file (one per line) or from a CSV column, and reports:
- missing utm_source / utm_medium / utm_campaign
- uppercase letters, spaces or characters outside [a-z0-9._-] in UTM values
- utm_medium values outside the allowed list (GA4 default channel friendly)
- UTM parameters after the # fragment (analytics never sees them)
- repeated parameters, empty values, unknown utm_* keys
- UTMs on internal links (--internal example.com)
- the same campaign spelled several ways (case/separator variants)
Usage:
python3 utm_lint.py links.txt
python3 utm_lint.py campaigns.csv --column url --internal example.com
python3 utm_lint.py links.txt --mediums cpc,email,social,paid_social,affiliate,referral,display
Exit codes: 0 clean, 1 problems found, 2 bad input.
"""
from __future__ import annotations
import argparse
import csv
import re
import sys
from collections import defaultdict
from urllib.parse import parse_qsl, urlsplit
REQUIRED = ("utm_source", "utm_medium", "utm_campaign")
KNOWN = set(REQUIRED) | {
"utm_term", "utm_content", "utm_id", "utm_source_platform",
"utm_creative_format", "utm_marketing_tactic",
}
DEFAULT_MEDIUMS = (
"cpc,paid_social,paid_search,display,email,social,organic_social,affiliate,"
"referral,sms,push,audio,video,qr,partner"
)
VALUE_RE = re.compile(r"^[a-z0-9._-]+$")
def load_urls(path: str, column: str | None) -> list[str]:
with open(path, newline="", encoding="utf-8") as f:
if column:
reader = csv.DictReader(f)
if column not in (reader.fieldnames or []):
raise SystemExit(f"error: column '{column}' not in {reader.fieldnames}")
return [row[column].strip() for row in reader if row.get(column, "").strip()]
return [line.strip() for line in f if line.strip() and not line.startswith("#")]
def norm(value: str) -> str:
return re.sub(r"[\s_.-]+", "-", value.strip().lower())
def lint(urls: list[str], mediums: set[str], internal: list[str]) -> int:
problems = 0
campaigns: dict[str, set[str]] = defaultdict(set)
for n, url in enumerate(urls, 1):
issues: list[str] = []
parts = urlsplit(url)
if not parts.scheme or not parts.netloc:
print(f"[{n}] {url}\n - not an absolute URL")
problems += 1
continue
if "utm_" in parts.fragment:
issues.append("utm parameters are inside the #fragment; move them before '#'")
pairs = parse_qsl(parts.query, keep_blank_values=True)
utm = [(k, v) for k, v in pairs if k.lower().startswith("utm_")]
host = parts.netloc.lower().split(":")[0]
if internal and any(host == d or host.endswith("." + d) for d in internal):
if utm:
issues.append("UTMs on an internal link; they overwrite the real traffic source")
if issues:
print(f"[{n}] {url}")
for i in issues:
print(f" - {i}")
problems += 1
continue
seen: dict[str, int] = defaultdict(int)
for k, v in utm:
seen[k] += 1
if k != k.lower():
issues.append(f"parameter name '{k}' must be lowercase")
if k.lower() not in KNOWN:
issues.append(f"unknown parameter '{k}'")
if v == "":
issues.append(f"{k} is empty")
elif not VALUE_RE.match(v):
issues.append(f"{k}='{v}' has uppercase, spaces or other characters (use a-z 0-9 . _ -)")
for k, c in seen.items():
if c > 1:
issues.append(f"{k} appears {c} times")
values = {k.lower(): v for k, v in utm}
for k in REQUIRED:
if k not in values:
issues.append(f"missing {k}")
medium = values.get("utm_medium", "")
if medium and medium.lower() not in mediums:
issues.append(f"utm_medium='{medium}' not in allowed list")
if values.get("utm_campaign"):
campaigns[norm(values["utm_campaign"])].add(values["utm_campaign"])
if issues:
problems += 1
print(f"[{n}] {url}")
for i in issues:
print(f" - {i}")
for key, spellings in sorted(campaigns.items()):
if len(spellings) > 1:
problems += 1
print(f"campaign spelled {len(spellings)} ways (reports will split): {sorted(spellings)}")
print(f"\nchecked {len(urls)} URL(s): {'clean' if not problems else f'{problems} problem(s)'}")
return 1 if problems else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("file", help="text file with one URL per line, or a CSV with --column")
ap.add_argument("--column", help="CSV column holding the URL")
ap.add_argument("--mediums", default=DEFAULT_MEDIUMS, help="comma-separated allowed utm_medium values")
ap.add_argument("--internal", default="", help="comma-separated internal domains that must not carry UTMs")
a = ap.parse_args()
try:
urls = load_urls(a.file, a.column)
except OSError as err:
print(f"error: {err}", file=sys.stderr)
return 2
if not urls:
print("error: no URLs found", file=sys.stderr)
return 2
mediums = {m.strip().lower() for m in a.mediums.split(",") if m.strip()}
internal = [d.strip().lower() for d in a.internal.split(",") if d.strip()]
return lint(urls, mediums, internal)
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.