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.

  • Growth & SEO
  • $utm-attribution
  • 183 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- utm-attribution

Installs to ~/.agents/skills/utm-attribution/ for every project on this machine.

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

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.

~/.agents/skills/utm-attribution/SKILL.md

--- name: utm-attribution description: Defines and enforces UTM tagging conventions (utm_source, utm_medium, utm_campaign, utm_content, utm_term, utm_id) that map cleanly onto GA4 default… license: MIT ---

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

  1. Read AGENTS.md, then look for existing conventions and link builders: rg -n "utm_(source|medium|campaign)" -g '!node_modules' | head -50.
  2. 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.
  3. 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

ParameterMeaningRuleExamples
utm_sourceWho sent the traffic (site, platform, publication)Lowercase platform or publisher name, no TLDgoogle, linkedin, newsletter, partner-acme
utm_mediumMarketing channel typeFixed allowed list only (below)cpc, paid_social, email
utm_campaignThe initiative<yyyy>-<qN or mm>-<slug> or a stable slug2026-q4-launch, spring-sale
utm_contentWhich creative or link within the campaignVariant identifiervideo-a, header-cta
utm_termPaid keyword or audienceUsually filled by dynamic params{keyword}
utm_idCampaign ID for cost data importPlatform or internal IDcmp_10443

Allowed utm_medium values, chosen to land in GA4 default channel groups:

utm_mediumGA4 default channel (with a matching source)
cpcPaid Search (source is a search engine) or Paid Social (source is a social site)
paid_socialPaid Social (any value starting with paid counts as paid)
displayDisplay
emailEmail
socialOrganic Social
affiliateAffiliates
referralReferral
smsSMS
audio, videoAudio / Paid or Organic Video depending on source

Rules:

  • Lowercase everything, hyphens inside values, no spaces. GA4 values are case-sensitive in reports: Email and email become 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_location instead.
  • 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.md plus the link builder; update both together.

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):

PlatformWhereExample
Google AdsAuto-tagging (gclid) on; optional final URL suffixutm_source=google&utm_medium=cpc&utm_campaign={_campaign}&utm_term={keyword}&utm_id={campaignid} with a custom {_campaign} parameter
Microsoft AdsAuto-tagging setting can append UTMs; msclkidCheck the account setting before adding manual UTMs to avoid duplicates
MetaAd 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.

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

QuestionModel / 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 optimisationGA4 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 cyclesSelf-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=partner for 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.yamlgenerated from this page

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.py141 lines, 5.6 KB
#!/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.

esc