diff --git a/.agents/skills/utm-builder-v2/SKILL.md b/.agents/skills/utm-builder-v2/SKILL.md
new file mode 100644
index 0000000..246ee5f
--- /dev/null
+++ b/.agents/skills/utm-builder-v2/SKILL.md
@@ -0,0 +1,33 @@
+---
+name: utm-builder-v2
+description: "Use for explaining, planning, operating, debugging, or documenting this repository's governed UTM Builder v2 workflows, including initiatives, campaigns, link issuance, duplicates, bulk operations, reporting, Slack, API, and GTM Data MCP. Do not use for unrelated generic UTM advice."
+---
+
+# UTM Builder v2
+
+Treat the repository's implementation and documentation as the source of truth. Do not invent registry records, identifiers, taxonomy values, permissions, production status, or behavior that has not been verified.
+
+## Route the request
+
+- For user workflows, terminology, picker behavior, campaign reassignment, presets, duplicates, and bulk issuance, read `../../../docs/user-manual.md`.
+- For live agent operations and MCP tool contracts, read `../../../docs/mcp.md`, then follow [the governed operation workflow](references/operate.md).
+- For installing, configuring, verifying, or troubleshooting this Codex skill and its optional MCP connection, read `../../../docs/codex-skill.md`.
+- For attribution, joins, GA4/PostHog, Snowflake/Mode, or recovery logic, read `../../../docs/reporting-contract.md`.
+- For Slack, API, administration, or deployment questions, read the matching file: `../../../docs/slack.md`, `../../../docs/api.md`, `../../../docs/admin-manual.md`, or `../../../docs/deployment-vercel.md`.
+- For code changes or diagnosis, inspect the relevant implementation and tests first. Start with `../../../src/services/links.ts`, `../../../src/services/campaigns.ts`, `../../../src/contracts/public-api.ts`, and `../../../src/mcp/server.ts` as applicable. Make the smallest safe change and run proportionate tests, type checks, and builds.
+
+## Preserve the domain model
+
+- The implemented hierarchy is Initiative -> Campaign -> Link. A campaign belongs to at most one initiative.
+- `utm_campaign` is globally unique. Represent variations within a campaign with fields such as `utm_content` or `utm_term`, not duplicate campaign names.
+- Reassigning a campaign changes the grouping for future links. Existing issued links retain the initiative recorded when they were issued.
+- The campaign picker favors planned and active campaigns. Completed and archived campaigns remain available through search.
+- Public identifiers are immutable prefixed ULIDs: initiatives use `rpi_`, campaigns use `rpc_`, and links use `rpl_`. Issued URLs use the campaign ID in `utm_id`.
+- Previewing does not write. Issuance is fail-closed and transactional.
+- Exact duplicates reuse the existing link by default. Authorized overrides require a reason and remain auditable.
+
+## Set accurate expectations
+
+Distinguish implemented behavior from planned work. This repository documents a proof of concept and its production-readiness requirements; do not describe a personal or test deployment as the production system. When documentation and code disagree, report the mismatch and cite the current implementation rather than silently choosing one.
+
+When the live GTM Data MCP tools are unavailable, provide guidance or prepare inputs only. Do not claim to have searched, created, moved, or issued anything.
diff --git a/.agents/skills/utm-builder-v2/agents/openai.yaml b/.agents/skills/utm-builder-v2/agents/openai.yaml
new file mode 100644
index 0000000..1359cdb
--- /dev/null
+++ b/.agents/skills/utm-builder-v2/agents/openai.yaml
@@ -0,0 +1,7 @@
+interface:
+ display_name: "UTM Builder v2"
+ short_description: "Plan and operate governed campaign links"
+ default_prompt: "Use $utm-builder-v2 to help with this governed UTM Builder request."
+
+policy:
+ allow_implicit_invocation: true
diff --git a/.agents/skills/utm-builder-v2/references/operate.md b/.agents/skills/utm-builder-v2/references/operate.md
new file mode 100644
index 0000000..304d216
--- /dev/null
+++ b/.agents/skills/utm-builder-v2/references/operate.md
@@ -0,0 +1,34 @@
+# Governed operation workflow
+
+Use this workflow only when the repository's GTM Data MCP tools are available and authenticated for the current user. The exact schemas and current tool list live in `../../../../docs/mcp.md`; read that document before calling tools.
+
+## Read-only work
+
+1. List current reference data before selecting taxonomy, initiatives, or campaigns.
+2. Search existing links and campaigns before proposing a new record.
+3. Keep identifiers returned by the registry; do not infer them from display names.
+
+Read-only requests do not require confirmation unless the host environment imposes a stricter rule.
+
+## Create or issue
+
+1. Resolve the user's destination, initiative, campaign, taxonomy, and optional content or term values from current registry data.
+2. Search for the expected campaign and destination to expose existing records and duplicates.
+3. Preview the link. Show the normalized URL, validation errors or warnings, duplicate result, and any material defaults.
+4. Ask for explicit confirmation of the exact write when the user has not already confirmed that exact result.
+5. Call the matching create or issue tool with `confirmed=true`. For single issuance, reuse one stable idempotency key for retries of the same intended write.
+6. Return the registered ID and final URL supplied by the service. State clearly whether the result was newly issued or an existing exact duplicate was reused.
+
+Never bypass preview or confirmation, hand-construct a URL and call it issued, or retry with a new idempotency key after an uncertain response.
+
+## Batch issuance
+
+Preview and summarize the batch first, including row-level errors and duplicate outcomes. Confirm the concrete batch before issuing it. Preserve the service's per-row results and do not imply that failed rows were written.
+
+## Reporting and audit
+
+Use exact `utm_id` values as durable join keys and keep raw observed values as evidence. Treat downstream capture and warehouse transformation as separate from registry issuance. Read `../../../../docs/reporting-contract.md` before recommending queries, attribution logic, or recovery behavior.
+
+## Stop conditions
+
+Stop before writing when authentication is missing, permissions are insufficient, required registry values cannot be resolved, preview reports validation errors, or the user's confirmation no longer matches the proposed write. Explain the blocker and the next safe action.
diff --git a/.claude/skills/utm-builder-v2/SKILL.md b/.claude/skills/utm-builder-v2/SKILL.md
new file mode 100644
index 0000000..3142e23
--- /dev/null
+++ b/.claude/skills/utm-builder-v2/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: utm-builder-v2
+description: >-
+ Generate governed, deduplicated Runpod campaign URLs through the UTM Builder
+ registry API instead of hand-crafting UTM query strings. Use whenever a task
+ needs a tracked marketing/campaign link — building a UTM link, "tag this URL",
+ adding utm_source/medium/campaign, a paid-ad or email or social destination
+ URL, a bulk set of tracked links, or looking up an existing campaign/link in
+ the registry. Also use when another workflow (e.g. a campaign builder) needs
+ UTM-stamped destination URLs. Every link goes through the shared API so it
+ gets a canonical campaign ID (utm_id), taxonomy validation, duplicate
+ protection, and an audit record — never assemble utm_* parameters by hand.
+---
+
+# UTM Builder — governed campaign links via the registry API
+
+The Runpod UTM Builder owns one authoritative campaign/link registry and one
+server-side generation API. This skill calls `/api/v1`; it holds **no** URL,
+UTM, or ID logic of its own. That is the point: identifiers, normalization,
+taxonomy, duplicate fingerprints, and audit all live server-side, so links this
+skill issues are consistent with the web app, the bulk grid, and Slack.
+
+## The one rule
+
+**Never hand-assemble `utm_*` query strings.** A hand-made link has no canonical
+campaign ID, bypasses taxonomy and duplicate checks, and leaves no audit record —
+which is exactly what this system exists to prevent. Always resolve/create a
+campaign and issue the link through the API below.
+
+## Setup
+
+- **Base URL**: the deployment origin (e.g. `https://utm-builder-runpod.vercel.app`), configurable per environment.
+- **Auth**: a personal access token from the app's **API access** page, sent as `Authorization: Bearer rpt_...`. Tokens are user-scoped, expire in 1–90 days, and carry only the scopes the user's role allows.
+- Confirm the token and its capabilities first with `GET /api/v1/session` before offering write actions.
+
+## Core workflow (single link)
+
+1. **Resolve the campaign.** `GET /api/v1/campaigns`, find the intended one, and use its `id` (an `rpc_…` value). If none fits, create one explicitly with `POST /api/v1/campaigns` (`{ "name": "..." }`) — **never** invent a campaign just because a name was typed; creation is always deliberate. The campaign's `id` is what rides in `utm_id`.
+2. **Preview.** `POST /api/v1/links/preview` with the destination, `campaignId`, `utmSource`, `utmMedium`, optional `utmContent`/`utmTerm`, and optional `presetKey`. The response returns the normalized destination, the assembled `finalUrl`, `validation.findings`, and any `duplicates`. Surface errors/warnings to the user before issuing.
+3. **Issue.** `POST /api/v1/links` with the same body **plus an `Idempotency-Key` header** (any stable unique string for the attempt; required). On success you get the committed `link` including its `finalUrl`, `id` (`rpl_…`), and `utmId`.
+
+`utmSource`/`utmMedium` must be values from the governed taxonomy (`GET /api/v1/taxonomy`); a `presetKey` from `GET /api/v1/presets` can fill sensible defaults.
+
+## Bulk
+
+`POST /api/v1/batches` with `{ "source": "csv"|"paste"|"grid", "rows": [ ...up to 200 link objects... ] }`. One batch ID is returned; a bad row fails alone without dropping the others.
+
+## Search the registry
+
+`GET /api/v1/links?...` (scope `utm:read`) — filter by any ID, UTM field, platform, status, dates, etc. Prefer this over guessing whether a link already exists.
+
+## Handling responses
+
+- **`201`** — issued. Use `link.finalUrl`; report `link.utmId` as the reporting key.
+- **`409 exact_duplicate`** — an identical governed link exists (`existingLinkId`, `existingUrl`). Reuse it; do not reissue. Only override with an explicit `duplicateAction: "override"` + `duplicateReason`, and only if the token's role permits.
+- **`409 campaign_duplicate`** — a near-identical campaign exists (`candidates`). Reuse one instead of creating another.
+- **`422 validation_failed`** — blocked; read `findings[]` (bad domain, missing field, taxonomy miss, malformed macro). Fix and retry.
+- **`400 invalid_request`** — body failed schema (`issues[]`). **`429 rate_limited`** — back off.
+
+## Reporting
+
+Report and group by **exact `utm_id`** equality (the `rpc_` campaign ID), never by substring-matching `utm_campaign` names. For launches spanning multiple campaigns, group by initiative. See `docs/reporting-contract.md`.
+
+For the full endpoint table, scopes, request/response schemas, and copy-paste examples, read [reference.md](reference.md). The live OpenAPI document is at `GET /api/v1/openapi`.
diff --git a/.claude/skills/utm-builder-v2/reference.md b/.claude/skills/utm-builder-v2/reference.md
new file mode 100644
index 0000000..39bdf28
--- /dev/null
+++ b/.claude/skills/utm-builder-v2/reference.md
@@ -0,0 +1,135 @@
+# UTM Builder API reference (for the `utm-builder-v2` skill)
+
+All paths are under the deployment origin. Send `Authorization: Bearer rpt_…` on
+every request. This mirrors [`docs/api.md`](../../../docs/api.md); the live,
+authoritative schema is `GET /api/v1/openapi` (OpenAPI 3.1).
+
+## Scopes
+
+| Scope | Allows |
+|---|---|
+| `utm:read` | Session, taxonomy, presets, registry search |
+| `utm:preview` | Normalize/validate/duplicate-check without writing |
+| `utm:issue` | Issue single links and batches |
+| `utm:campaigns:write` | Create campaigns (mint `rpc_` IDs) |
+| `utm:initiatives:write` | Create initiatives (mint `rpi_` IDs) |
+
+A token cannot exceed its user's role. Investigator tokens receive only the
+read-only subset — inspect `GET /api/v1/session` capabilities before offering writes.
+
+## Endpoints
+
+| Method | Path | Scope | Purpose |
+|---|---|---|---|
+| GET | `/api/v1/openapi` | — | OpenAPI discovery document |
+| GET | `/api/v1/session` | (any) | Verify token + principal + capabilities |
+| GET | `/api/v1/taxonomy` | `utm:read` | Governed sources and mediums |
+| GET | `/api/v1/presets` | `utm:read` | Platform presets and their defaults |
+| GET, POST | `/api/v1/initiatives` | `utm:read` / `utm:initiatives:write` | List or create initiatives |
+| GET, POST | `/api/v1/campaigns` | `utm:read` / `utm:campaigns:write` | List or create campaigns |
+| POST | `/api/v1/links/preview` | `utm:preview` | Validate/normalize/dedupe, no write |
+| GET, POST | `/api/v1/links` | `utm:read` / `utm:issue` | Search or issue governed links |
+| POST | `/api/v1/batches` | `utm:issue` | Issue up to 200 links in one batch |
+
+## Request bodies
+
+**Link** (preview, issue, and each batch row):
+
+```json
+{
+ "destination": "https://www.runpod.io/serverless",
+ "campaignId": "rpc_01J...",
+ "utmSource": "linkedin-paid",
+ "utmMedium": "paid",
+ "utmContent": "founder-video", // optional
+ "utmTerm": "gpu-cloud", // optional
+ "presetKey": "linkedin", // optional; from /api/v1/presets
+ "duplicateAction": "override", // optional; requires role + reason
+ "duplicateReason": "..." // required with override
+}
+```
+
+- `destination` accepts a bare domain, `www.`, or full URL; the server normalizes to HTTPS, preserves unrelated query params + fragments, and strips/replaces any existing governed `utm_*` params.
+- `campaignId` is mandatory — resolve it from `/api/v1/campaigns` or create the campaign first.
+
+**Campaign** (`POST /api/v1/campaigns`): `{ "name": "2026 Q3 Product Launch", "initiativeId"?: "rpi_…", "product"?, "campaignType"?, "startDate"?, "endDate"?, "description"? }`. `utmCampaign` defaults to the canonicalized name.
+
+**Initiative** (`POST /api/v1/initiatives`): `{ "name": "2026 Product Launch", "product"?, "initiativeType"?, "startDate"?, "endDate"?, "description"? }`.
+
+**Batch** (`POST /api/v1/batches`): `{ "source": "csv" | "paste" | "grid", "rows": [ , ... ] }` (1–200 rows).
+
+## Examples
+
+Resolve or create a campaign, then issue a link (curl):
+
+```bash
+BASE="https://utm-builder-runpod.vercel.app"
+TOKEN="rpt_..."
+
+# 1. find an existing campaign
+curl -s "$BASE/api/v1/campaigns" -H "Authorization: Bearer $TOKEN"
+
+# 2. (only if none fits) create one explicitly
+CID=$(curl -s "$BASE/api/v1/campaigns" -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"2026 Q3 Product Launch"}' | jq -r .campaign.id)
+
+# 3. preview
+curl -s "$BASE/api/v1/links/preview" -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d "{\"destination\":\"runpod.io/serverless\",\"campaignId\":\"$CID\",\"utmSource\":\"linkedin-paid\",\"utmMedium\":\"paid\",\"utmContent\":\"founder-video\"}"
+
+# 4. issue (Idempotency-Key header is required)
+curl -s "$BASE/api/v1/links" -H "Authorization: Bearer $TOKEN" \
+ -H 'Content-Type: application/json' \
+ -H "Idempotency-Key: $(uuidgen)" \
+ -d "{\"destination\":\"runpod.io/serverless\",\"campaignId\":\"$CID\",\"utmSource\":\"linkedin-paid\",\"utmMedium\":\"paid\",\"utmContent\":\"founder-video\"}"
+```
+
+Node (fetch):
+
+```js
+const base = process.env.UTM_BASE, token = process.env.UTM_TOKEN;
+const h = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
+
+const body = {
+ destination: "runpod.io/serverless",
+ campaignId: "rpc_01J...",
+ utmSource: "linkedin-paid",
+ utmMedium: "paid",
+ utmContent: "founder-video",
+};
+
+// preview first
+const preview = await fetch(`${base}/api/v1/links/preview`, { method: "POST", headers: h, body: JSON.stringify(body) }).then(r => r.json());
+if (!preview.ok) console.warn(preview.validation.findings);
+
+// then issue
+const res = await fetch(`${base}/api/v1/links`, {
+ method: "POST",
+ headers: { ...h, "Idempotency-Key": crypto.randomUUID() },
+ body: JSON.stringify(body),
+});
+if (res.status === 409) {
+ const dup = await res.json(); // reuse dup.existingUrl instead of reissuing
+} else {
+ const { link } = await res.json(); // link.finalUrl, link.id (rpl_), link.utmId (rpc_)
+}
+```
+
+## Error codes
+
+| Status | `error.code` | Meaning / action |
+|---|---|---|
+| 201 | — | Issued. Use `link.finalUrl`; `link.utmId` is the reporting key. |
+| 400 | `invalid_request` | Body failed schema; inspect `issues[]`. |
+| 401 | `unauthorized` | Missing/expired/revoked token. |
+| 403 | `forbidden` | Token lacks the scope, or role can't do this. |
+| 409 | `exact_duplicate` | Identical link exists (`existingLinkId`, `existingUrl`) — reuse it. |
+| 409 | `campaign_duplicate` | Near-identical campaign exists (`candidates`) — reuse one. |
+| 422 | `validation_failed` | Blocked; fix per `findings[]`, then retry. |
+| 429 | `rate_limited` | Back off and retry. |
+
+## ID glossary
+
+`rpi_` initiative · `rpc_` campaign (carried in `utm_id`) · `rpl_` link (optional public `rp_link_id`) · `rpb_` batch. All are prefixed ULIDs, immutable, non-sequential.
diff --git a/.gitignore b/.gitignore
index 96f9890..67b805b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,7 @@ node_modules/
.npm-cache/
.next/
.data/
-.env
-.env.local
+.env*
*.tsbuildinfo
coverage/
+.vercel/
diff --git a/README.md b/README.md
index f655ff9..d355e22 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
Internal tool for issuing governed campaign URLs. Campaign managers create one link or a bulk batch; every link is recorded in a single authoritative registry with stable reporting identifiers, duplicate protection, and immutable audit records.
+Canonical repository: [runpod/utm_builder_v2](https://github.com/runpod/utm_builder_v2)
+
## Why it exists
Ad-hoc UTM tagging produces unjoinable campaign names, silent duplicates, and reports built on substring matching. V2 replaces that with:
@@ -35,6 +37,8 @@ Use the smallest useful end-to-end pilot first: web + registry + approved taxono
- Manifest V3 Chrome side panel: capture the current page or right-click a link, preview, issue, log, and copy without leaving the platform workflow
- Supported `/api/v1` surface with bearer scopes, stable error envelopes, CORS allowlisting, OpenAPI, and idempotent single-link issuance
- Authenticated remote MCP endpoint with read, preview, search, campaign/initiative creation, single issuance, and batch issuance tools
+- Bundled Claude [Agent Skill](docs/claude-skill.md) (`.claude/skills/utm-builder-v2/`) that teaches AI assistants to issue governed links through the API instead of hand-crafting `utm_*` strings
+- Repository-scoped Codex skill that routes users and agents to the current Builder rules, code, reporting contract, and safe MCP workflow
- Shared **Runpod GTM Ops** Slack app: `/utm` and global shortcuts for previewed single issuance, CSV upload for 1–200 links, Slack identity mapping, signed-request verification, and direct-message batch results
- GTM operating catalog for people, teams, agencies, vendors, systems, accounts, integrations, data definitions, measurement assets, reports, policies, and runbooks
- Typed ownership and lineage relationships, readiness checks, and role-aware restricted-record visibility
@@ -51,8 +55,10 @@ All entry points use the same server-side generation and registry service, so va
| **Chrome extension** | Creating a governed link while working in HubSpot, Google Ads, LinkedIn, Meta, Reddit, CM360, or another browser-based platform | Captures the current page or a selected link, then previews, issues, logs, and copies the URL from a Manifest V3 side panel without leaving the platform workflow |
| **Versioned API** | Repeatable system integrations and automation | Supported `/api/v1` endpoints, scoped bearer tokens, stable error envelopes, OpenAPI documentation, CORS allowlisting, and idempotent issuance |
| **MCP server** | Governed AI-assisted and conversational workflows | Authenticated tools for reference data, preview, search, campaign/initiative creation, and single or batch issuance; writes remain attributable to the user and normal audit trail |
+| **Claude skill** | AI assistants (Claude Code and other skill-aware agents) generating links in the course of other work | A bundled Agent Skill (`.claude/skills/utm-builder-v2/`) that redirects an agent from hand-crafting `utm_*` strings to the `/api/v1` registry, inheriting canonical IDs, taxonomy, duplicate protection, and audit; see [docs/claude-skill.md](docs/claude-skill.md) |
+| **Codex skill** | Guided Builder questions, repository work, reporting guidance, and MCP-assisted operations | Project-specific instructions that travel with the repository; live registry actions still require a separately configured MCP connection and authorized Builder token |
-The Chrome extension, API, and MCP server do not contain separate UTM logic. They call the same preview and issuance service as the web app, preventing interface-specific rules or records from drifting apart.
+The Chrome extension, API, MCP server, Claude skill, and Codex skill do not define separate UTM logic. Live operations call the same preview and issuance service as the web app, preventing interface-specific rules or records from drifting apart.
## Architecture
@@ -151,6 +157,10 @@ No database setup required: with `DATABASE_URL` unset, the app auto-provisions a
The dev auth provider (`AUTH_PROVIDER=dev`, the default) selects the identity from the `rp_dev_identity` cookie (set via `POST /api/session {"email": ...}`); it defaults to the dev admin and refuses to run in production. Deployed Preview and Production environments use `AUTH_PROVIDER=sso` with the signed-principal proxy contract in [the Vercel deployment guide](docs/deployment-vercel.md).
+### Codex skill setup
+
+The repository includes `$utm-builder-v2` under `.agents/skills`. Open this checkout in Codex to use it; no separate skill installation is required. The skill can explain or work on the repository without live access. To search, preview, or issue against a deployed registry, separately configure the GTM Data MCP and a scoped Builder token. See [the Codex skill installation and setup guide](docs/codex-skill.md).
+
## Commands
| Command | What it does |
@@ -181,6 +191,8 @@ Health check: `GET /api/health` (checks API + database).
| [docs/api.md](docs/api.md) | Developers: `/api/v1`, bearer scopes, idempotency, errors, examples |
| [docs/browser-extension.md](docs/browser-extension.md) | Users/operators: extension workflow, installation, security, rollout |
| [docs/mcp.md](docs/mcp.md) | AI-tool users/operators: MCP setup, tool safety, token rotation |
+| [docs/claude-skill.md](docs/claude-skill.md) | AI-agent users: the bundled Claude skill for issuing governed links via the API |
+| [docs/codex-skill.md](docs/codex-skill.md) | Codex users/operators: skill discovery, MCP connection, verification, security, and troubleshooting |
| [docs/slack.md](docs/slack.md) | Slack users/admins: `/utm`, shortcuts, bulk CSV, identity, app manifest, rollout, and failure behavior |
| [docs/gtm-data-mcp.md](docs/gtm-data-mcp.md) | GTM teams/AI users: complete catalog, ownership, lineage, dictionary, template, and tool model |
| [docs/source-reconciliation.md](docs/source-reconciliation.md) | Administrators/operators: Notion scanning, proposals, authority, scheduling, and failure safety |
diff --git a/docs/admin-manual.md b/docs/admin-manual.md
index 534d75b..9abbed8 100644
--- a/docs/admin-manual.md
+++ b/docs/admin-manual.md
@@ -128,6 +128,7 @@ Investigators can read runs; only admins trigger them.
- Set `EXTENSION_IDS` in production. An empty allowlist disables production extension redirects/CORS.
- For an incident, revoke the affected token first, then filter audit events by actor and time. Bearer-authenticated writes store the access-token record as `context.credentialId`; `lastUsedAt` narrows the activity window, and issued records identify every URL affected.
- MCP exposes no admin/configuration tools. Any future administrative integration requires a separate decision and narrower scopes.
+- The repository's Codex skill is instruction-only and grants no access by itself. Live operations still use the MCP token's Builder user, role, and scopes; onboarding and verification are documented in [codex-skill.md](codex-skill.md).
Audit events (`rpa_`) are append-only, written in the same transaction as the change they describe, with before/after snapshots (secret-looking keys are redacted) and optional reason and correlation ID. Query via `GET /api/admin/audit`; add `format=csv` for export.
diff --git a/docs/api.md b/docs/api.md
index 3a1a51d..e67ac59 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -82,3 +82,7 @@ Only `chrome-extension://` origins receive CORS headers. Product
## Compatibility
Additive fields may appear in `/api/v1` responses. Clients should ignore unknown fields. Breaking request/response or behavioral changes require `/api/v2`; taxonomy and preset changes are data/config versions and do not change the API version.
+
+## Claude skill
+
+A bundled Claude Agent Skill at `.claude/skills/utm-builder-v2/` wraps this API so AI assistants issue governed links through `/api/v1` rather than hand-assembling `utm_*` strings. It is a client of this contract and holds no rules of its own. See [docs/claude-skill.md](claude-skill.md).
diff --git a/docs/claude-skill.md b/docs/claude-skill.md
new file mode 100644
index 0000000..f8b6525
--- /dev/null
+++ b/docs/claude-skill.md
@@ -0,0 +1,54 @@
+# Claude skill — governed UTM links for AI agents
+
+The repository ships a Claude [Agent Skill](https://docs.claude.com/en/docs/agents-and-tools/agent-skills) at
+[`.claude/skills/utm-builder-v2/`](../.claude/skills/utm-builder-v2/SKILL.md). It teaches Claude Code and other
+skill-aware agents to generate governed campaign links **through the `/api/v1`
+registry** rather than hand-assembling `utm_*` query strings — making an AI
+assistant just another well-behaved client of the one authoritative registry,
+alongside the web app, bulk grid, Slack, and browser extension.
+
+## Why it exists
+
+An agent left to its own devices will happily concatenate
+`?utm_source=…&utm_medium=…`. Such a link has no canonical campaign ID, skips
+taxonomy and duplicate checks, and leaves no audit trail — the exact failure
+modes this project removes. The skill redirects that instinct to the shared API,
+so agent-issued links get a canonical `utm_id`, validation, duplicate
+protection, and an audit record identical to any human-issued link.
+
+## Contents
+
+| File | Purpose |
+|---|---|
+| `.claude/skills/utm-builder-v2/SKILL.md` | Trigger description + the core resolve-campaign → preview → issue workflow, the "never hand-craft UTMs" rule, error handling, and reporting guidance |
+| `.claude/skills/utm-builder-v2/reference.md` | Full endpoint table, scopes, request/response schemas, and copy-paste curl/Node examples |
+
+The skill contains no URL, UTM, or ID logic of its own; it delegates entirely to
+the server, so it stays correct as the rules evolve.
+
+## Enabling it
+
+- **Claude Code in this repo**: skills under `.claude/skills/` are discovered automatically; no install step.
+- **Elsewhere / other agents**: copy the `utm-builder-v2` folder into the consuming project's `.claude/skills/`, or package it per your agent runtime's skill mechanism.
+
+## Configuration the operator provides
+
+The skill needs two things at use time, supplied by the user/agent environment (never hard-coded in the skill):
+
+1. **Base URL** — the deployment origin (e.g. `https://utm-builder-runpod.vercel.app`).
+2. **Bearer token** — a personal access token from the app's **API access** page. Tokens are user-scoped, expire in 1–90 days, are revocable, and carry only the scopes the user's role allows. An agent should call `GET /api/v1/session` first to confirm the token and its capabilities.
+
+## Relationship to the other integration docs
+
+- [`docs/api.md`](api.md) — the underlying `/api/v1` contract the skill wraps.
+- [`docs/mcp.md`](mcp.md) — the MCP server, a complementary agent surface for the GTM catalog and templates; the skill focuses on link generation.
+- [`docs/reporting-contract.md`](reporting-contract.md) — how the `utm_id` the skill returns is used as the durable reporting key.
+- [`docs/codex-skill.md`](codex-skill.md) — the Codex counterpart (`.agents/skills/utm-builder-v2/`). The two are per-agent siblings of the same capability: Claude Code discovers this one under `.claude/skills/`, Codex discovers its own under `.agents/skills/`.
+
+## Safety
+
+The skill inherits every server-side guarantee: taxonomy enforcement, exact/near
+duplicate detection, fail-closed issuance, role and scope checks, and the
+append-only audit log. An agent cannot bypass these — it can only ask the API,
+and the API refuses anything ungoverned. Duplicate overrides still require an
+explicit reason and a role that permits them.
diff --git a/docs/codex-skill.md b/docs/codex-skill.md
new file mode 100644
index 0000000..929cb72
--- /dev/null
+++ b/docs/codex-skill.md
@@ -0,0 +1,103 @@
+# Codex skill — installation and setup
+
+The repository includes a project-scoped Codex skill at [`.agents/skills/utm-builder-v2`](../.agents/skills/utm-builder-v2). It gives Codex the Builder's current domain model, documentation routes, implementation entry points, and safe MCP operating sequence.
+
+The skill and the GTM Data MCP have different jobs:
+
+| Component | What it provides | What it does not provide |
+|---|---|---|
+| Repository skill | Builder terminology, rules, documentation routing, code context, and the approved search/preview/confirm workflow | Registry access, credentials, or permission to write |
+| GTM Data MCP | Authenticated tools for live registry search, preview, campaign/initiative creation, and link issuance | A separate UTM implementation or a bypass around Builder roles and validation |
+| Builder access token | The current user's scopes and audit identity | New permissions beyond the user's active Builder account and role |
+
+The skill is useful without MCP for explanation, planning, documentation, reporting guidance, and repository work. Live registry operations require both a reachable MCP endpoint and an authorized token.
+
+## 1. Install the repository skill
+
+Prerequisites: a local Codex client that supports skills—the ChatGPT desktop app, Codex CLI, or Codex IDE extension—and access to this repository.
+
+```bash
+git clone https://github.com/kenlim-mops/utm_builder_v2.git
+cd utm_builder_v2
+git switch main
+```
+
+Open the repository root, or any directory beneath it, as the Codex working directory. No separate skill copy or package installation is required: Codex discovers repository skills under `.agents/skills` between the working directory and Git root.
+
+Verify discovery in one of these ways:
+
+- ChatGPT desktop app: open **Skills** in the sidebar and look for **UTM Builder v2**.
+- Codex CLI or IDE extension: run `/skills` or type `$utm-builder-v2` in the prompt.
+- If the skill does not appear after pulling an update, restart the Codex client and confirm the working directory is inside this Git repository.
+
+Do not copy this skill to a global skill directory. Its instructions intentionally resolve the code and documentation relative to this repository. A future standalone distribution should be packaged separately and remove that repository-relative assumption.
+
+Official behavior and discovery locations: [Build skills — OpenAI](https://learn.chatgpt.com/docs/build-skills).
+
+## 2. Use the skill without live registry access
+
+Invoke it explicitly when you want predictable routing:
+
+```text
+Use $utm-builder-v2 to explain whether these campaign and initiative names follow the current model.
+```
+
+```text
+Use $utm-builder-v2 to investigate this Builder bug, make the smallest safe fix, and run the relevant checks.
+```
+
+Codex may also select the skill automatically when a request matches its description. Without GTM Data MCP access, Codex should prepare or explain work only; it must not claim it searched, created, moved, or issued a live registry record.
+
+## 3. Add live GTM Data MCP access
+
+Complete these prerequisites first:
+
+1. Use an approved Builder deployment and confirm the user has an active Builder account.
+2. In the web app, open **API access**, create a dedicated token, and choose **MCP client**.
+3. Grant only the scopes needed for the intended work. Read-only discovery uses `gtm:read` and/or `utm:read`; previews add `utm:preview`; writes require the matching issuance, campaign, or initiative scope.
+4. Store the plaintext token once in the approved local secret mechanism. Never commit it to this repository or paste it into shared documentation.
+
+In Codex, open **Settings → MCP servers → Add server**, select **Streamable HTTP**, name it `runpod-gtm-data`, and enter:
+
+```text
+https:///api/mcp
+```
+
+Configure bearer-token authentication using the token created above, save, and restart the client. For a file-based local configuration, add this to `~/.codex/config.toml` and make the named variable available to the environment that starts Codex:
+
+```toml
+[mcp_servers.runpod-gtm-data]
+url = "https:///api/mcp"
+bearer_token_env_var = "RUNPOD_GTM_DATA_TOKEN"
+default_tools_approval_mode = "writes"
+```
+
+Use the actual approved registry host. Do not put the token itself in `config.toml`, a repository-scoped `.codex/config.toml`, shell history, or screenshots. Codex MCP configuration is shared by the desktop app, CLI, and IDE extension on the same Codex host. See [Model Context Protocol — OpenAI](https://learn.chatgpt.com/docs/extend/mcp) and [the Builder MCP reference](mcp.md).
+
+## 4. Verify the setup safely
+
+1. In Codex, run `/mcp` and confirm `runpod-gtm-data` is connected.
+2. Invoke `$utm-builder-v2` and ask it to list current UTM reference data.
+3. Search for an existing campaign or link.
+4. Preview a link and confirm the response shows the normalized URL, warnings, and duplicate result without creating a record.
+5. Test a write only in an approved environment with a disposable test record. Review the exact preview, explicitly confirm the write, and verify the returned `rpc_`, `rpi_`, or `rpl_` identifier in the web registry and audit trail.
+
+The skill requires search before creation, preview before issuance, explicit confirmation for writes, and a stable idempotency key for single-link retries. Server-side roles, scopes, validation, duplicates, transactions, and audit remain authoritative.
+
+## 5. Troubleshooting
+
+| Symptom | Check |
+|---|---|
+| Skill is missing | Confirm the checkout contains `.agents/skills/utm-builder-v2/SKILL.md`, the working directory is inside the repository, and Codex has been restarted after the pull. |
+| Skill loads but no live tools appear | The skill does not install MCP. Check `/mcp`, the configured URL, client restart, and network access to the registry host. |
+| MCP returns `401` | The token is missing, expired, revoked, or not visible to the process that started Codex. Replace or rotate it; do not expose it in logs. |
+| MCP returns `403` or omits write tools | The Builder user, role, or token lacks the required scope. Request the minimum appropriate access rather than broadening the token silently. |
+| Preview works but issuance stops | Resolve validation/duplicate findings and confirm the exact proposed write. `confirmed=true` is required server-side and does not replace user approval. |
+| Production host is unavailable | Continue with documentation or local code work only. Do not point production workflows at a personal/test deployment or claim a registry change occurred. |
+
+## 6. Updates and maintenance
+
+- Pull `main` to receive skill and documentation updates. Codex detects skill changes automatically; restart if an update is not visible.
+- Keep domain rules in the application code and canonical documentation. The skill should route to them rather than duplicate full manuals.
+- When the skill changes, run the bundled `skill-creator` validator against `.agents/skills/utm-builder-v2`, verify every referenced repository path, and test at least one explanation prompt plus the read-only MCP flow.
+- Personal bearer tokens are suitable only for an approved pilot. Production should move to Runpod-approved organization OAuth when available, as tracked in [decisions.md](decisions.md).
diff --git a/docs/decisions.md b/docs/decisions.md
index 283f864..e29a750 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -49,7 +49,7 @@ Format per entry: Status / Context / Options / Decision / Justification / Tradeo
- **Status:** Accepted
- **Context:** Single builder, bulk grid, paste, CSV, and future clients (browser helper, platform integrations) all generate links.
- **Options:** (a) per-surface generation logic; (b) shared client-side library; (c) one server-side service every entry point calls.
-- **Decision:** All entry points call `previewLink`/`issueLink` in `src/services/links.ts`; bulk (`src/services/batches.ts`) wraps the same call per row.
+- **Decision:** All entry points call `previewLink`/`issueLink` in `src/services/links.ts`; bulk (`src/services/batches.ts`) wraps the same call per row. This extends to non-UI clients: the web app, Chrome extension, `/api/v1`, MCP server, Slack app, and the bundled Claude skill (`.claude/skills/utm-builder-v2/`) are all thin callers of the same service — the skill in particular carries no UTM/ID logic and only invokes the API.
- **Justification:** Exactly one implementation of normalization, validation, fingerprints, ID minting, duplicate policy, and audit — divergence is structurally impossible. Client-side logic couldn't enforce DB-backed duplicate checks or mint trusted IDs.
- **Tradeoffs:** Every client needs network access to the registry to create links (accepted: issuance is rare, clicks are common, and clicks don't need the registry).
- **Revisit trigger:** An offline-issuance requirement (would need signed deferred issuance, not client minting).
@@ -119,9 +119,9 @@ Format per entry: Status / Context / Options / Decision / Justification / Tradeo
- **Status:** Accepted
- **Context:** Needs relational integrity (partial unique indexes carry core invariants), Vercel serverless compatibility, and zero-friction local dev.
- **Options:** DBs: Postgres vs. SQLite vs. hosted-proprietary. ORM: Drizzle vs. Prisma vs. raw SQL. Dev DB: Docker Postgres vs. SQLite vs. PGlite.
-- **Decision:** PostgreSQL in production (`DATABASE_URL`, node-postgres). Local dev without `DATABASE_URL` uses PGlite — real Postgres compiled to WASM, persisted at `.data/pglite` — running the *same* Drizzle migrations from `./drizzle`. Drizzle ORM + drizzle-kit for schema/migrations; migrations auto-apply on boot via `getDb()`.
+- **Decision:** PostgreSQL in production (`DATABASE_URL`, node-postgres). Local dev without `DATABASE_URL` uses PGlite — real Postgres compiled to WASM, persisted at `.data/pglite` — running the *same* Drizzle migrations from `./drizzle`. Drizzle ORM + drizzle-kit for schema/migrations; production migrations run as an explicit release step (`npm run db:migrate`), while local PGlite migrations apply on boot.
- **Justification:** Partial unique indexes (duplicate blocking, HubSpot GUID uniqueness) are Postgres features the design depends on. PGlite gives `git clone && npm run dev` with zero infrastructure *and* dialect fidelity — no "works on SQLite, fails on Postgres" drift. Drizzle stays close to SQL and supports both drivers with one schema.
-- **Tradeoffs:** PGlite is single-process (fine for dev); boot-time migration on serverless has cold-start/race caveats (mitigated by the explicit release-step recommendation in [deployment-vercel.md](deployment-vercel.md) §5).
+- **Tradeoffs:** PGlite is single-process (fine for dev); an explicit production migration step adds release coordination but avoids serverless cold-start latency and concurrent migration races (see [deployment-vercel.md](deployment-vercel.md) §5).
- **Revisit trigger:** Provider approval outcome (Open decisions) may add pooling requirements; PGlite maturity issues would push dev to Docker Postgres.
## 13. Export-first platform support
@@ -313,6 +313,16 @@ Format per entry: Status / Context / Options / Decision / Justification / Tradeo
- **Tradeoffs:** Misconfiguration blocks legitimate Slack use until corrected; web/API recovery paths remain available.
- **Revisit trigger:** Slack identity is enforced by an approved organization-wide gateway with equivalent or stronger controls.
+## 32. Repository-scoped Codex skill complements MCP
+
+- **Status:** Accepted
+- **Context:** Codex needs the Builder's current domain model, documentation routes, and safe operating sequence, but copying those rules into personal prompts would drift and could be mistaken for live access.
+- **Options:** (a) rely on ad hoc prompts; (b) publish a global standalone skill; (c) version a repository-scoped skill that can optionally use the separately configured GTM Data MCP.
+- **Decision:** Keep `$utm-builder-v2` under `.agents/skills` in this repository. It routes to current code and canonical documentation and can support explanation or implementation without MCP. Live registry work requires an independently configured MCP endpoint and the current user's scoped Builder token.
+- **Justification:** The guidance travels with code review and version history while authentication, authorization, validation, duplicate control, transactions, and audit remain server-enforced.
+- **Tradeoffs:** The skill is available only when Codex works within this repository; users must configure MCP separately; repository-relative references prevent treating the folder as a portable global skill.
+- **Revisit trigger:** Runpod wants organization-wide installation outside this repository or approves a distributable plugin with organization OAuth.
+
---
## Open decisions
diff --git a/docs/deployment-vercel.md b/docs/deployment-vercel.md
index 00e9536..5c73150 100644
--- a/docs/deployment-vercel.md
+++ b/docs/deployment-vercel.md
@@ -83,8 +83,8 @@ Integration contract:
## 5. Migrations strategy
- Migrations live in `./drizzle` and are generated by `npm run db:generate`.
-- **Auto-run on boot:** `getDb()` (`src/db/client.ts`) applies pending migrations on first database use in each fresh deployment. This keeps deploys simple, but on serverless the first request pays the cost and concurrent cold starts can race on migration locks.
-- **Recommendation:** run `npm run db:migrate` as an explicit release step (CI/CD, against the production `DATABASE_URL`) before promoting a deployment. Boot-time migration then becomes a no-op safety net.
+- **Production/Preview:** run `npm run db:migrate` as an explicit release step (CI/CD, against that environment's `DATABASE_URL`) before promoting a deployment. `getDb()` does not migrate a configured Postgres database at request time by default.
+- **Local PGlite:** migrations still apply automatically on boot when `DATABASE_URL` is unset. `RUN_MIGRATIONS_ON_BOOT=true` opts a configured Postgres database into boot-time migration, but is not recommended for serverless deployments because cold starts can race.
- Review generated SQL before release; prefer additive migrations (the schema history is append-friendly by design).
## 6. Seeding
@@ -171,9 +171,10 @@ Also take periodic config exports (`GET /api/admin/export`) as a lightweight, di
11. Create a seven-day test token under **API access**; verify `/api/v1/session`, then revoke it and verify the same request returns 401
12. From the allowlisted extension, capture a current page, preview, issue, and open the resulting registry record
13. Connect an MCP client to `/api/mcp`; list tools and call `utm_list_reference_data` before attempting any write
-14. Call `gtm_get_data_definition` for `utm_id` and confirm a verified definition is returned
-15. If Notion reconciliation is enabled: create a paused test connector, scan manually, verify a proposal appears without changing the catalog, then reject it with a reason
-16. Import/update `slack/manifest.json`, approve it in Slack, then execute the signed-request, single-link, duplicate-reuse, two-row batch, identity-denial, and GTM MCP smoke tests in [slack.md](slack.md)
+14. Open the repository in Codex, verify `$utm-builder-v2` is discovered, then use it with the configured MCP connection for one read-only search and preview; do not treat skill discovery as proof of authentication
+15. Call `gtm_get_data_definition` for `utm_id` and confirm a verified definition is returned
+16. If Notion reconciliation is enabled: create a paused test connector, scan manually, verify a proposal appears without changing the catalog, then reject it with a reason
+17. Import/update `slack/manifest.json`, approve it in Slack, then execute the signed-request, single-link, duplicate-reuse, two-row batch, identity-denial, and GTM MCP smoke tests in [slack.md](slack.md)
## 12. Production readiness checklist
@@ -192,6 +193,7 @@ Also take periodic config exports (`GET /api/admin/export`) as a lightweight, di
- [ ] Config export taken and stored
- [ ] Smoke test (§11) passed
- [ ] API/MCP tokens have an owner, expiry/rotation policy, and secret-storage standard
+- [ ] If Codex is enabled for the pilot, users follow [codex-skill.md](codex-skill.md), keep tokens out of the repository, and have verified skill discovery plus a read-only MCP call
- [ ] GTM catalog/source-proposal steward and review SLA assigned
- [ ] Every enabled Notion connector mapping tested in paused/review-first mode; `NOTION_API_TOKEN` scoped only to approved sources
- [ ] Platform bulk templates remain draft until account-specific export/import certification is complete
diff --git a/docs/gtm-data-mcp.md b/docs/gtm-data-mcp.md
index 1c24270..6ef8c2e 100644
--- a/docs/gtm-data-mcp.md
+++ b/docs/gtm-data-mcp.md
@@ -52,6 +52,8 @@ Generic client configuration:
Use the secret-management syntax supported by the client. Never commit a production token.
+Codex users can pair this server with the repository's `$utm-builder-v2` skill. The skill supplies Runpod-specific operating guidance; this MCP server supplies live tools and enforces the user's Builder identity and scopes. See [codex-skill.md](codex-skill.md) for installation, secure client configuration, verification, and troubleshooting.
+
## Tool inventory
### GTM operating context (read-only)
diff --git a/docs/mcp.md b/docs/mcp.md
index 4c16b9d..a86dccb 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -32,6 +32,10 @@ Generic client configuration:
Use the secret-management syntax supported by the chosen client; do not paste a production token into a checked-in configuration file.
+### Codex skill and client setup
+
+This repository includes the `$utm-builder-v2` skill for Codex. The skill is discovered automatically when the repository is the working directory; it supplies workflow guidance but does not install this MCP connection or grant registry access. Configure the Streamable HTTP endpoint and a dedicated, scoped token separately, then verify the connection with a read-only reference-data call before attempting any preview or write. Full installation, `config.toml`, verification, and troubleshooting steps are in [codex-skill.md](codex-skill.md).
+
## Tools
| Tool | Behavior |
@@ -64,3 +68,7 @@ No MCP tool can edit governance settings, roles, audit records, or external mapp
- Rotate before expiry and revoke the replaced token.
- Investigate unexpected `lastUsedAt` activity and revoke immediately.
- Production should migrate from personal bearer tokens to Runpod-approved OAuth when the organization selects a provider that supports MCP clients.
+
+## Related: the Claude skill
+
+The MCP server is one AI surface; the bundled Claude Agent Skill (`.claude/skills/utm-builder-v2/`, documented in [docs/claude-skill.md](claude-skill.md)) is the complementary one. The MCP server suits conversational, tool-calling clients and also exposes the GTM catalog and templates; the skill teaches any skill-aware agent the link-generation workflow over `/api/v1`. Both are governed clients of the same registry and require a scoped bearer token.
diff --git a/docs/pilot-governance.md b/docs/pilot-governance.md
index aa2fa82..69e0a43 100644
--- a/docs/pilot-governance.md
+++ b/docs/pilot-governance.md
@@ -13,7 +13,7 @@ This is the operating plan for moving UTM Builder & Registry V2 from a working a
| Operational | Governed links are the default for in-scope work | Adoption and data-quality thresholds met for two review cycles |
| Scaled | More channels and clients can be enabled safely | Per-channel certification, support capacity, stable reporting joins |
-The browser extension, API, MCP, Slack, source reconciliation, and bulk-template library remain modular clients or capabilities. They do not need to be activated together.
+The browser extension, API, MCP, repository Codex skill, Slack, source reconciliation, and bulk-template library remain modular clients or capabilities. They do not need to be activated together. The Codex skill may be used for repository guidance without MCP; live registry use follows the same MCP approval and token controls as any other client.
## Pilot scope
@@ -80,7 +80,7 @@ Approve SSO/database, owners, effective date, taxonomy, two presets, GA4/PostHog
### Phase 2 — channel expansion
-Certify additional presets against current platform behavior, add campaign teams and agencies, then enable the lowest-friction approved clients (Slack and/or extension). Keep web and CSV as recovery paths.
+Certify additional presets against current platform behavior, add campaign teams and agencies, then enable the lowest-friction approved clients (Slack, extension, and/or Codex with MCP). Keep web and CSV as recovery paths. Codex onboarding follows [codex-skill.md](codex-skill.md); the skill itself does not grant production access.
### Phase 3 — operational reporting
diff --git a/docs/user-manual.md b/docs/user-manual.md
index ca69850..e3c748d 100644
--- a/docs/user-manual.md
+++ b/docs/user-manual.md
@@ -14,7 +14,7 @@ Audience: campaign managers and anyone issuing governed campaign URLs.
## 2. Creating a single link
1. **Search, then pick (or create) the campaign.** Links cannot be issued without a canonical campaign. The campaign supplies `utm_id` (its `rpc_` ID) and `utm_campaign` (its canonical slug); you do not type either. When a spacing/punctuation variant already exists, creation returns a candidate to reuse. Only an administrator can create a genuinely separate campaign, and must record why.
-2. **Pick a preset** (defaults to `generic`). Presets can pre-fill `utm_source`/`utm_medium` and may require extra fields (e.g. Google Ads, LinkedIn, Meta, and HubSpot/Email require `utm_content`).
+2. **Pick a preset** (defaults to `generic`). Presets can pre-fill `utm_source`/`utm_medium` and may require extra fields (e.g. Google Ads, LinkedIn, X Ads, Meta, and HubSpot/Email require `utm_content`).
3. **Enter the destination.** Bare domains, `www.` hosts, and `http://` URLs are accepted and normalized to HTTPS. Any query params or fragment you include are preserved — except governed params, which are replaced.
4. **Enter source / medium / content / term.** Source and medium must exist in the governed taxonomy (aliases are accepted with a warning and resolved to the canonical value).
5. **Preview.** The preview (`POST /api/links/preview`) is a dry run: it validates, checks for duplicates, and shows the final URL with a placeholder link ID (`rpl_PREVIEW`). It never writes anything.
@@ -33,7 +33,7 @@ rp_initiative_id? (admin policy, default OFF), rp_link_id? (admin policy, defaul
### Fast access from other tools
-Use the browser extension when you need one URL while working in HubSpot or an ad platform: click the toolbar button for the current page, or right-click a link, then preview and issue from the side panel. In Slack, use `/utm [destination]` for one link or `/utm bulk` for a CSV of up to 200 rows. Use the web bulk flow for grid editing and exception repair. Approved scripts and AI tools use the versioned API/MCP server; every entry point creates the same registry records and cannot bypass validation or duplicate checks. See [browser-extension.md](browser-extension.md), [slack.md](slack.md), and [mcp.md](mcp.md).
+Use the browser extension when you need one URL while working in HubSpot or an ad platform: click the toolbar button for the current page, or right-click a link, then preview and issue from the side panel. In Slack, use `/utm [destination]` for one link or `/utm bulk` for a CSV of up to 200 rows. Use the web bulk flow for grid editing and exception repair. Approved scripts and AI tools use the versioned API/MCP server; every entry point creates the same registry records and cannot bypass validation or duplicate checks. Codex users who open this repository can invoke `$utm-builder-v2` for guided Builder and reporting work; live registry operations require the separately configured MCP connection and scoped token. See [browser-extension.md](browser-extension.md), [slack.md](slack.md), [mcp.md](mcp.md), and [codex-skill.md](codex-skill.md).
All bulk paths produce one **batch** (`rpb_...`) and run every row through the exact same issuance service as the single builder. The batch limit is admin-configurable (default **200** rows).
@@ -61,6 +61,8 @@ Behavior:
Rule of thumb: if you'd ever want a single rollup number for "the launch" across multiple campaigns, create the initiative first and attach campaigns to it.
+Each campaign can be assigned to at most one initiative. If the builder detects that the selected campaign belongs to a different initiative, it preserves both selections, blocks issuance, and asks you to resolve the mismatch explicitly. The campaign's creator, owner, or an administrator may change its initiative assignment with a required audit reason. Existing links keep the initiative recorded when they were issued; future links use the campaign's new assignment.
+
## 5. Reporting with exact IDs
- **Campaign performance:** filter on **equality** of `utm_id` (= the `rpc_` campaign ID). This is GA4's native session campaign ID dimension.
@@ -77,6 +79,8 @@ Seeded presets (all editable by admins):
| `generic` | url | — | — | verified |
| `google_ads` | url | `google-ads` / `paid` | `utm_content` | draft |
| `linkedin` | url | `linkedin-paid` / `paid` | `utm_content` | draft |
+| `x_organic` | url | `twitter-organic` / `organic` | — | draft |
+| `x_paid` | url | `twitter-paid` / `paid` | `utm_content` | draft |
| `meta` | url | `facebook-paid` / `paid` | `utm_content` | draft |
| `reddit` | url | `reddit-paid` / `paid` | — | draft |
| `cm360` | tracking_template | `programmatic` / `paid` | — | draft |
@@ -84,6 +88,7 @@ Seeded presets (all editable by admins):
| `event_qr` | qr_target | — / `event` | — | verified |
- Preset defaults fill blanks; anything you type explicitly wins.
+- X keeps the historical canonical sources `twitter-organic` and `twitter-paid`; `x-organic` and `x-paid` remain accepted aliases that normalize to those values.
- Presets whitelist **macros** (e.g. `{keyword}` for Google Ads, `{{ad.id}}` for Meta). Using a macro the preset doesn't support is a blocking error.
- A `draft` preset issues links with a warning: it has not been verified against current platform documentation. A `deprecated` preset blocks issuance.
@@ -175,7 +180,9 @@ Campaign and initiative ownership may be transferred only by an administrator. T
- **Direct ID lookup** — paste any `rp*_` ID (link, campaign, initiative, batch) into the search box.
- Filters: campaign, initiative, batch, status (`draft`/`issued`/`retired`), validation state, platform preset, creator, source, medium, duplicate-override flag, created before/after.
-**CSV export** (`GET /api/export/links`) honors the same filters and includes all identifiers, raw UTM values, the emitted `rp_*` params, platform, validation state, revision, and config version. Exports are audited. Note: an export returns at most 200 rows per request — narrow your filters for large registries.
+The registry table shows **Generated by** with the issuing user's name and email; if their user profile is unavailable, it shows the recorded user ID.
+
+**CSV export** (`GET /api/export/links`) honors the same filters and includes all identifiers, raw UTM values, the emitted `rp_*` params, platform, validation state, revision, config version, and the issuing user's ID, name, and email. Exports are audited. Note: an export returns at most 200 rows per request — narrow your filters for large registries.
## 11. ID glossary
diff --git a/drizzle/0005_add_x_presets.sql b/drizzle/0005_add_x_presets.sql
new file mode 100644
index 0000000..b4fc994
--- /dev/null
+++ b/drizzle/0005_add_x_presets.sql
@@ -0,0 +1,97 @@
+WITH "candidate_presets" (
+ "id",
+ "key",
+ "name",
+ "output_type",
+ "defaults",
+ "supported_macros",
+ "required_fields",
+ "docs_url"
+) AS (
+ VALUES
+ (
+ 'pre_01M28ZTW8XY9GXCCHH2SDH5ZWM',
+ 'x_organic',
+ 'X (Twitter) — Organic',
+ 'url',
+ '{"utm_medium":"organic","utm_source":"twitter-organic"}'::jsonb,
+ '[]'::jsonb,
+ '[]'::jsonb,
+ NULL
+ ),
+ (
+ 'pre_01M28ZTW906ZRETE2WZ9K1ZT1P',
+ 'x_paid',
+ 'X (Twitter) Ads',
+ 'url',
+ '{"utm_medium":"paid","utm_source":"twitter-paid"}'::jsonb,
+ '[]'::jsonb,
+ '["utm_content"]'::jsonb,
+ 'https://business.x.com/en/help/campaign-measurement-and-analytics'
+ )
+),
+"inserted" AS (
+ INSERT INTO "platform_presets" (
+ "id",
+ "key",
+ "name",
+ "output_type",
+ "defaults",
+ "supported_macros",
+ "required_fields",
+ "static_params",
+ "validation_rules",
+ "verification_state",
+ "docs_url"
+ )
+ SELECT
+ "id",
+ "key",
+ "name",
+ "output_type",
+ "defaults",
+ "supported_macros",
+ "required_fields",
+ '{}'::jsonb,
+ '{}'::jsonb,
+ 'draft',
+ "docs_url"
+ FROM "candidate_presets"
+ WHERE EXISTS (SELECT 1 FROM "config_versions" WHERE "id" = 1)
+ ON CONFLICT ("key") DO NOTHING
+ RETURNING *
+),
+"bumped" AS (
+ UPDATE "config_versions"
+ SET "version" = "version" + 1, "updated_at" = now()
+ WHERE "id" = 1 AND EXISTS (SELECT 1 FROM "inserted")
+ RETURNING "version"
+)
+INSERT INTO "audit_events" (
+ "id",
+ "actor_id",
+ "actor_email",
+ "action",
+ "entity_type",
+ "entity_id",
+ "after",
+ "reason",
+ "config_version",
+ "context"
+)
+SELECT
+ CASE "inserted"."key"
+ WHEN 'x_organic' THEN 'rpa_01M28ZVN5S41BFX6ZR4R0KSV4X'
+ ELSE 'rpa_01M28ZVN5VNGV8WTWZCH5A7WDT'
+ END,
+ 'system',
+ 'system@runpod.io',
+ 'preset.created',
+ 'platform_preset',
+ "inserted"."id",
+ to_jsonb("inserted"),
+ 'Add separate X organic and paid presets while preserving historical twitter-* canonical sources.',
+ "bumped"."version",
+ '{"source":"migration","migration":"0005_add_x_presets"}'::jsonb
+FROM "inserted"
+CROSS JOIN "bumped";
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 0173244..bdce177 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1788581084000,
"tag": "0004_backfill_record_owners",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1789155726523,
+ "tag": "0005_add_x_presets",
+ "breakpoints": true
}
]
}
diff --git a/next.config.ts b/next.config.ts
index b13e162..0e0a97a 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@electric-sql/pglite", "pg"],
+ // Drizzle loads SQL migrations from the filesystem at runtime. Include the
+ // complete folder in every server trace so Vercel functions can initialize
+ // and upgrade the registry database after deployment.
+ outputFileTracingIncludes: {
+ "/*": ["./drizzle/**/*"],
+ },
};
export default nextConfig;
diff --git a/scripts/poc-provision.ts b/scripts/poc-provision.ts
new file mode 100644
index 0000000..aa58375
--- /dev/null
+++ b/scripts/poc-provision.ts
@@ -0,0 +1,71 @@
+/**
+ * One-off POC provisioning: parse DATABASE_URL from a pulled Vercel env file,
+ * run migrations + seed, and ensure an admin user exists. Prints only
+ * non-sensitive counts. Not part of the app runtime.
+ */
+import { readFileSync } from "node:fs";
+import { eq } from "drizzle-orm";
+
+// Accept the connection string from DATABASE_URL directly, or from a pulled
+// env file passed as the first arg (usage: ).
+const envArg = process.argv[2];
+const adminEmail = (process.argv[3] ?? "").trim().toLowerCase();
+let url = process.env.DATABASE_URL ?? "";
+if (!url && envArg && envArg !== "-") {
+ const text = readFileSync(envArg, "utf8");
+ const match = text.match(/^DATABASE_URL=(.*)$/m);
+ if (match) url = match[1].trim().replace(/^["']|["']$/g, "");
+}
+if (!url || !/^postgres(ql)?:\/\//.test(url)) {
+ console.error("Provide a valid DATABASE_URL (env var) and admin email.");
+ process.exit(1);
+}
+process.env.DATABASE_URL = url;
+process.env.RUN_MIGRATIONS_ON_BOOT = "true"; // this script IS the migration step
+const finalAdmin = adminEmail && adminEmail.includes("@") ? adminEmail : "kenneth.lim@runpod.io";
+if (finalAdmin !== adminEmail) console.log(`admin email -> ${finalAdmin}`);
+
+const scheme = url.split("://")[0];
+const host = url.replace(/^[^@]*@/, "").split("/")[0];
+console.log(`DATABASE_URL scheme=${scheme} host=${host.replace(/:.*/, "")}`);
+
+async function main() {
+ const { getDb } = await import("@/db/client");
+ const { ensureSeed } = await import("@/db/seed");
+ const { users, campaigns, links } = await import("@/db/schema");
+ const { newId } = await import("@/core/ids");
+
+ const db = await getDb(); // runs migrations
+ await ensureSeed(db);
+
+ const [existing] = await db.select().from(users).where(eq(users.email, finalAdmin)).limit(1);
+ if (existing) {
+ if (existing.role !== "admin" || !existing.active) {
+ await db.update(users).set({ role: "admin", active: true }).where(eq(users.id, existing.id));
+ console.log(`promoted existing user to admin: ${finalAdmin}`);
+ } else {
+ console.log(`admin already present: ${finalAdmin}`);
+ }
+ } else {
+ await db.insert(users).values({
+ id: newId("user"),
+ email: finalAdmin,
+ name: finalAdmin.split("@")[0].replace(/[._-]+/g, " "),
+ role: "admin",
+ active: true,
+ });
+ console.log(`created admin user: ${finalAdmin}`);
+ }
+
+ const userCount = (await db.select().from(users)).length;
+ const campaignCount = (await db.select().from(campaigns)).length;
+ const linkCount = (await db.select().from(links)).length;
+ console.log(`counts -> users:${userCount} campaigns:${campaignCount} links:${linkCount}`);
+}
+
+main()
+ .then(() => process.exit(0))
+ .catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
diff --git a/src/app/api/auth/poc-login/route.ts b/src/app/api/auth/poc-login/route.ts
new file mode 100644
index 0000000..4a7de43
--- /dev/null
+++ b/src/app/api/auth/poc-login/route.ts
@@ -0,0 +1,41 @@
+import { cookies } from "next/headers";
+import { getDb } from "@/db/client";
+import { handle, json } from "@/server/http";
+import { assertRateLimit, clientIp } from "@/server/rate-limit";
+import { pocAuthEnabled } from "@/services/auth";
+import { recordAudit } from "@/services/audit";
+import { pocSignIn } from "@/services/poc-login";
+import { createSessionCookieValue, SESSION_COOKIE, SESSION_TTL_SECONDS } from "@/services/oidc";
+
+export const dynamic = "force-dynamic";
+
+/** POC-only email sign-in; disabled unless AUTH_PROVIDER=poc. */
+export async function POST(req: Request) {
+ return handle(async () => {
+ if (!pocAuthEnabled()) {
+ return json({ error: "POC sign-in is not enabled." }, { status: 400 });
+ }
+ assertRateLimit(`poc-login:${clientIp(req)}`, 20);
+ const { email } = (await req.json()) as { email?: string };
+ if (!email) return json({ error: "email is required" }, { status: 400 });
+
+ const db = await getDb();
+ const actor = await pocSignIn(db, email);
+ await recordAudit(db, actor, {
+ action: "auth.signed_in",
+ entityType: "user",
+ entityId: actor.id,
+ context: { mode: "poc" },
+ });
+
+ const jar = await cookies();
+ jar.set(SESSION_COOKIE, createSessionCookieValue(actor.email), {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: SESSION_TTL_SECONDS,
+ });
+ return json({ ok: true, email: actor.email, role: actor.role });
+ });
+}
diff --git a/src/app/api/campaigns/[id]/route.ts b/src/app/api/campaigns/[id]/route.ts
index ace321d..793d655 100644
--- a/src/app/api/campaigns/[id]/route.ts
+++ b/src/app/api/campaigns/[id]/route.ts
@@ -1,3 +1,4 @@
+import { campaignUpdateSchema } from "@/contracts/public-api";
import { getDb } from "@/db/client";
import { canManage, requireUser } from "@/services/auth";
import { campaignDetail, updateCampaign } from "@/services/campaigns";
@@ -30,7 +31,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
const actor = await requireUser();
const { id } = await params;
const db = await getDb();
- const { reason, ...patch } = await req.json();
+ const { reason, ...patch } = campaignUpdateSchema.parse(await req.json());
const campaign = await updateCampaign(db, actor, id, patch, reason ?? null);
return json({ campaign });
});
diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts
index ca67a7b..0c16b38 100644
--- a/src/app/api/campaigns/route.ts
+++ b/src/app/api/campaigns/route.ts
@@ -1,15 +1,27 @@
import { campaignInputSchema } from "@/contracts/public-api";
import { getDb } from "@/db/client";
import { requireUser } from "@/services/auth";
-import { createCampaign, listCampaigns } from "@/services/campaigns";
+import {
+ createCampaign,
+ listCampaignPickerGroups,
+ listCampaigns,
+ searchCampaigns,
+} from "@/services/campaigns";
import { handle, json } from "@/server/http";
export const dynamic = "force-dynamic";
-export async function GET() {
+export async function GET(req: Request) {
return handle(async () => {
- await requireUser();
+ const actor = await requireUser();
const db = await getDb();
+ const url = new URL(req.url);
+ const query = url.searchParams.get("q")?.trim();
+ if (query) return json({ campaigns: await searchCampaigns(db, query) });
+ if (url.searchParams.get("view") === "picker") {
+ const initiativeId = url.searchParams.get("initiativeId")?.trim() || undefined;
+ return json({ groups: await listCampaignPickerGroups(db, actor, initiativeId) });
+ }
return json({ campaigns: await listCampaigns(db) });
});
}
diff --git a/src/app/components.tsx b/src/app/components.tsx
index 412e680..279ee84 100644
--- a/src/app/components.tsx
+++ b/src/app/components.tsx
@@ -118,6 +118,20 @@ export function Nav() {
}, []);
const isOidc = authProvider === "google" || authProvider === "oidc";
+ const isPoc = authProvider === "poc";
+ const [pocEmail, setPocEmail] = useState("");
+ const pocSignIn = useCallback(async () => {
+ if (!pocEmail.trim()) return;
+ setSwitching(true);
+ setSwitchError("");
+ try {
+ await api("/api/auth/poc-login", { method: "POST", body: JSON.stringify({ email: pocEmail.trim() }) });
+ window.location.reload();
+ } catch (err) {
+ setSwitchError(errText(err));
+ setSwitching(false);
+ }
+ }, [pocEmail]);
// Surface coarse sign-in errors passed back from the OIDC callback.
const authErrorCode =
typeof window !== "undefined"
@@ -158,7 +172,7 @@ export function Nav() {