# Designing a campaign type on SubtleAds

You're helping someone decide what an app can sell sponsors — possibly
before they've registered. A campaign type is one JSON document:
`name`, `description`, `pricing`, `delivery`, `slots[]`, `assets[]`,
`fields[]`, `validationRules[]`. It validates against `campaignTypeSpec`
(`packages/schema/src/campaign-type.ts` in the platform repo) — everything
downstream (booking form, quote, rejection messages) is generated from it,
so getting the shape right here is the whole job. There is no separate
"basics" object — `name`/`description` are flat, top-level fields.

## Decision procedure

1. **List the places an ad can appear → `slots`.** Each slot has a `key`,
   `label`, and usually points at exactly one asset via `assetKey`. Several
   slots may share one asset. Leave `assetKey` out entirely for a
   **text-only slot** — a placement the app draws from the campaign's fields
   (sponsor name, brand colour, blurb, CTA label) with no uploaded file
   involved at all. It sells, serves and bills exactly like any other slot;
   the sponsor is simply never asked for a creative for it. Prefer that over
   declaring an asset the app doesn't actually render: the booking form asks
   for every declared asset, so a placeholder image gets collected from the
   sponsor and then silently never drawn. An optional `role` (`intro`,
   `ambient`, `mark`, `outro`, `interstitial`) is a platform-wide
   vocabulary, skippable. A
   slot can also optionally set `previewSurface` — a screenshot of the real
   place with the ad's footprint pinned onto it, so a sponsor sees their
   creative composited in before booking. That's set up separately, once the
   app is registered (below) — nothing to include in this JSON beyond the key.

2. **For each asset, declare the shape you want — never what you'll accept.**
   `aspect` (e.g. `"16:9"`), `targetWidth`/`maxDurationSec`, `formats`,
   `maxSizeMB`. The ingest pipeline conforms whatever master the sponsor
   uploads to exactly that shape; it only rejects when no safe transform
   exists (source under `minSourceWidth`, or a `contain` crop needing >3x
   stretch) — never for "wrong size."

3. **Pick exactly one pricing model:**
   - `flat` / `cpd` — guaranteed, date-bound placement, sold regardless of
     traffic. `cpd` for variable-length flights.
   - `cpm` — the standard usage unit, billed per confirmed impression,
     shown to sponsors as "$X CPM."
   - `cpc` — only if a click is a real, instrumented action.
   - `cps` — one price per genuine session the ad was shown in, immune to
     multi-slot inflation by construction (five slots filled in one session
     still bills once).
   - `sov` — a guaranteed *share* of decisions, NOT a consumption unit.
     Cannot pair with `exclusive_per_slot` delivery (rule below).
   - `METERED_MODELS` (`cpm`/`cpc`/`cps`) draw down a budget until it's
     spent. `RESERVED_MODELS` (`flat`/`cpd`/`sov`) buy a fixed window
     regardless of volume.

4. **Pick exclusivity.** `delivery.exclusivity` defaults to `"shared"`.
   Set `"exclusive_per_slot"` only when "one sponsor owns this slot" is a
   real product fact, not a pricing tactic. Two combinations are schema-
   enforced, not advisory — do not propose either:
   - `exclusive_per_slot` + a non-null `delivery.defaultShareOfVoicePct`
     together (rejected: can't mean "you alone" and "you get 50%" at once).
   - `sov` pricing with anything but `shared` delivery (rejected: a
     guaranteed share requires something to share).
   - If proposing `exclusive_per_slot`, also set `maxConsecutiveRenewals`
     (default 2) and `bookingHorizonDays` (default 90) — the guardrails
     that keep "exclusive sponsorship" from becoming permanent lockout.

5. **Other fields ride separately.** Anything besides a file is a
   `fields[]` entry: `boolean`, `select`, `text`, `link`, `number`,
   `date`, `color`. A `color` field's optional `contrastFloors` is
   checked at submission, before moderation or payment.

## Worked examples — paste-ready, adapt them, don't invent new field names

### 3D game-world billboard
Real, live example — see `packages/db/src/seed.ts` (DrivingTest's
"3D Billboard") and `apps/demo-drivingtest/src/main.ts` for the SDK wiring
that renders it.
```json
{
  "name": "3D Billboard",
  "pricing": { "model": "cpm", "rateMinor": 400, "currency": "usd", "minBudgetMinor": 10000 },
  "delivery": {
    "exclusivity": "shared",
    "dedupeWindowSec": 30,
    "frequencyCap": { "count": 4, "perHours": 24 }
  },
  "assets": [
    { "key": "billboard_texture", "kind": "image", "label": "Billboard artwork", "aspect": "16:9", "targetWidth": 1024, "minSourceWidth": 1024 }
  ],
  "slots": [
    { "key": "billboard_highway", "label": "Highway billboard", "assetKey": "billboard_texture", "role": "ambient" },
    { "key": "billboard_roundabout", "label": "Roundabout billboard", "assetKey": "billboard_texture", "role": "ambient" }
  ]
}
```

### Mobile app banner + interstitial
Two campaign types, not one — they're priced differently because they cost
different amounts of attention.
```json
{
  "name": "Bottom banner",
  "pricing": { "model": "cpm", "rateMinor": 200, "currency": "usd", "minBudgetMinor": 5000 },
  "delivery": { "exclusivity": "shared", "frequencyCap": { "count": 6, "perHours": 24 } },
  "assets": [{ "key": "banner_image", "kind": "image", "label": "Banner", "aspect": "6:1", "cropStrategy": "smart" }],
  "slots": [{ "key": "bottom_banner", "label": "Bottom banner", "assetKey": "banner_image", "role": "ambient" }]
}
```
```json
{
  "name": "Interstitial",
  "pricing": { "model": "cpm", "rateMinor": 1500, "currency": "usd", "minBudgetMinor": 15000 },
  "delivery": { "exclusivity": "shared", "frequencyCap": { "count": 2, "perHours": 24 } },
  "assets": [{ "key": "interstitial_image", "kind": "image", "label": "Interstitial", "aspect": "9:16", "cropStrategy": "smart" }],
  "slots": [{ "key": "between_screens", "label": "Between screens", "assetKey": "interstitial_image", "role": "interstitial" }]
}
```

### Text-only sponsor panel — no creative at all
A billable placement the app composes itself, for a surface with no image
element in it. `assets` is omitted entirely and no slot names one.
```json
{
  "name": "Round sponsor",
  "pricing": { "model": "cps", "rateMinor": 300, "currency": "usd", "minBudgetMinor": 5000 },
  "delivery": { "exclusivity": "shared" },
  "fields": [
    { "key": "brand_name", "type": "text", "label": "Sponsor name", "maxLength": 40 },
    { "key": "brand_color", "type": "color", "label": "Brand colour" },
    { "key": "blurb", "type": "text", "label": "One-line pitch", "maxLength": 90 },
    { "key": "cta_label", "type": "text", "label": "Button label", "maxLength": 24 }
  ],
  "slots": [
    { "key": "round_start", "label": "Round start panel", "role": "intro" },
    { "key": "round_end", "label": "Round end panel", "role": "outro" }
  ]
}
```
Those four field keys are the platform's reserved ones, so they arrive at the
app in the decision response's `brand` object rather than having to be read
out of the free-form `fields` bag. Text stays sharp at every density and
there is nothing to half-load while a round is starting, which is usually the
real reason a panel is composed rather than uploaded.

### Newsletter / content sponsorship
```json
{
  "name": "Newsletter sponsor",
  "pricing": { "model": "flat", "amountMinor": 20000, "unit": "flight", "currency": "usd", "minFlightDays": 7, "maxFlightDays": 30 },
  "delivery": { "exclusivity": "exclusive_per_slot", "maxConsecutiveRenewals": 2, "bookingHorizonDays": 90 },
  "assets": [{ "key": "sponsor_logo", "kind": "image", "label": "Logo", "aspect": "3:1", "cropStrategy": "contain" }],
  "fields": [
    { "key": "sponsor_link", "type": "link", "label": "Sponsor link" },
    { "key": "pitch", "type": "text", "label": "One-line pitch", "maxLength": 140 }
  ],
  "slots": [{ "key": "issue_sponsor", "label": "Issue sponsor", "assetKey": "sponsor_logo", "role": "mark" }]
}
```
Not `cpm` — there's no reliable per-open confirm signal for an email.

### Podcast / audio pre-roll
```json
{
  "name": "Episode sponsor",
  "pricing": { "model": "cpd", "rateMinor": 5000, "currency": "usd", "minFlightDays": 1, "maxFlightDays": 30 },
  "delivery": { "exclusivity": "shared" },
  "assets": [{ "key": "preroll_audio", "kind": "audio", "label": "Pre-roll audio", "maxDurationSec": 30, "loudnessLufs": -16 }],
  "slots": [
    { "key": "preroll", "label": "Pre-roll", "assetKey": "preroll_audio", "role": "intro" },
    { "key": "midroll", "label": "Mid-roll", "assetKey": "preroll_audio", "role": "interstitial" }
  ]
}
```
`cps` is the harder-but-more-honest option if playback-continued-past-the-spot
is genuinely instrumented; `flat`/`cpd` per episode is the honest default
otherwise — a play count isn't a confirmed listen.

## Turning this into a real campaign type

Once the app is registered, the fastest path is pasting the whole JSON
document in one shot — there's no need to walk the guided tabs field by
field, and this is the intended way to hand someone a finished proposal:

1. Sign in as that app's own team and go to `/app/types/new`.
2. Click "Whole document as JSON" in the left sidebar (or go straight to
   `/app/types/new?tab=json`).
3. Paste the JSON into the textarea, replacing what's there.
4. Click "Apply to all sections" — each top-level key is checked against
   its own section's schema and merged into a draft. You don't have to
   supply every field: anything omitted falls back to the schema's own
   default (`delivery.exclusivity` defaults to `"shared"`,
   `dedupeWindowSec` to `30`, and so on) — a minimal document is a
   valid document. Don't pad a proposal with defaulted values just to
   look complete.
5. Click "Create campaign type" at the top of the page. This is the one
   moment the *whole* document is re-validated together, including the
   cross-field rules below — if something's wrong it names the exact
   field and reason, and nothing is created until it's clean.

Verified end to end: a document with only `name`, `pricing`, a partial
`delivery`, one `asset`, and its `slots` — no `fields`, no
`validationRules` — creates successfully.

## Rules the schema enforces — do not propose a spec that violates these

- No two fields, assets, or slots may share a `key`.
- Every slot's `assetKey`, where it has one, must reference a declared
  asset. A slot with no `assetKey` is a text-only placement — legal, and
  the way to say this one is drawn from fields alone.
- An image asset with `powerOfTwo: true` must derive power-of-two
  dimensions at its `aspect`/`targetWidth`, or drop the flag.
- The two exclusivity/`sov` conflicts in step 4 above.

## Once the app is registered

Fetch `/app/docs/agent.md` from inside that app's own console. Each app has
its own portal origin, so that file does not live on the host you fetched
*this* one from: take the host you fetched this file from and prefix the app's
slug as a subdomain — e.g. an app with slug `drivingtest` on
`https://subtleads.example` serves it at
`https://drivingtest.subtleads.example/app/docs/agent.md` — unless the app has
attached its own custom domain, in which case use that. It requires a signed-in
session for a member of that app's team, so in practice whoever owns the app
fetches it and hands it to you.

That file has the real tenant slug; both base URLs spelled out absolutely (the
serving endpoint for `/v1/*`, and that app's own portal origin for every
`/api/*` and `/app/*` path, so nothing has to be guessed); this app's
*actual* existing campaign types (so you can tell whether a request is a new
type or an edit to one that already exists, before proposing a new document to
paste); the SDK-wiring protocol this file doesn't cover; and — if you're
vision-capable and already have a screenshot in hand — a stable
`/api/surfaces` endpoint for pinning `previewSurface`s directly, corner
coordinates and all.
