Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .agents/skills/utm-builder-v2/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .agents/skills/utm-builder-v2/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions .agents/skills/utm-builder-v2/references/operate.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions .claude/skills/utm-builder-v2/SKILL.md
Original file line number Diff line number Diff line change
@@ -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`.
135 changes: 135 additions & 0 deletions .claude/skills/utm-builder-v2/reference.md
Original file line number Diff line number Diff line change
@@ -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": [ <link object>, ... ] }` (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.
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ node_modules/
.npm-cache/
.next/
.data/
.env
.env.local
.env*
*.tsbuildinfo
coverage/
.vercel/
Loading