# Agent brief: build a high impact ad and deliver it to Aeon Ad Manager

This brief is for a coding agent (or a developer) that builds an ad creative and delivers it to a news site running Aeon Ad Manager. Read it all before you start.

You have:
- this kit;
- the creative guide (`CREATIVE-GUIDE.md`), which describes how every format looks and behaves;
- an order from the newspaper or the advertiser.

You work through the site's REST API.

## Hard rules

1. **Never put a test ad on a live site.** Build and check locally, deliver as a **draft**, and send a **preview link**. The ad becomes active only when the person who ordered it has approved the preview.
2. **Keep the password secret.** The application password comes from your secret store or an environment variable. Never write it into a file in the package, a log, a prompt, a commit or a message, and never print it.
3. **Do not repeat a failing call.** Read the error message, fix the cause, and try again once. If it still fails, stop and report.
4. **Change only what the order covers.** Do not change other ads, groups, placements or bookings, and do not touch the site's settings (your role cannot anyway).
5. **No code ads.** Your role can deliver images, formats and HTML5 packages. Ads with their own HTML, AdSense or Google Ad Manager code are created by the newspaper.

## What you need from the order

Ask for anything that is missing before you build:
- **The site address**, for example `https://news.example.com`.
- **A user name and an application password** for a user with the role **Ad agency**.
- **The format.** One of topscroll, midscroll (and whether `full` or `content` width), skin, reel or display (and its size).
- **The advertiser's name** as it is in Ad Manager.
- **The landing pages.** One web address per exit, for example `main` and `product`.
- **The artwork and copy**, or a brief for them.
- **When it runs,** and where: a placement, or a group (both are explained in step 9).

The role **Ad agency** can:
- upload images and HTML5 packages;
- create and activate ads;
- put ads in groups and placements, and book them;
- make preview links;
- read statistics.

It cannot change the site's settings, run imports, or create code ads.

## The API in short

- **Base:** `https://<site>/wp-json/aeon-ad-manager/v1/`.
- **Login:** HTTP Basic with the user name and the application password, always over HTTPS.
- **JSON in and out.** Database numbers can come back as strings, and JSON fields such as a creative's `config` come back as JSON text that you parse again. Send numbers as JSON numbers and `config` as an object.
- **Times** are UTC, `YYYY-MM-DD HH:MM:SS`. An end time is exclusive.
- **Changing an ad** (`PATCH ads/<id>`) needs the `revision` from your latest `GET`. A stale revision gives 409: read the ad again and retry.
- **Creatives never change.** A new version is a new creative, made active on the ad.

```sh
# Set these in your shell; never echo them.
export SITE='https://news.example.com'
export WP_USER='agency-name'
export WP_APP_PASSWORD='…from your secret store…'
API="$SITE/wp-json/aeon-ad-manager/v1"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" "$API/placements/map"
```

## Step by step

### 1. Build the creative

1. Copy the template for the format from `templates/<format>/` into a new folder for this ad.
2. Read the format's section in `CREATIVE-GUIDE.md`: sizes, safe zones, scroll progress and the rules.
3. Edit `index.html` and `manifest.json`:
   - `name` describes the ad;
   - `formats` lists the format;
   - `size` follows the format;
   - `exits` has `main` and any other exit names, with labels.
4. Keep the SDK at `assets/aeon-creative.js` and load it in `index.html`.
5. Put a `backup.webp` (at most 200 KB) at the root of the folder.
6. Make the **poster images** that the site shows before and instead of the HTML5. They are uploaded separately in step 3, so keep them outside the package folder:

   | Format | Poster images |
   |---|---|
   | topscroll | desktop 1920×800 and phone 1080×1800 |
   | midscroll | desktop 1920×1080 and phone 1080×1920 |
   | skin | left and right, each 1100×2560, and optionally a top band of 2200×300 |
   | reel | 3–5 slides of 1080×1920 and a backup image of the same size |
   | display | the ad's size |

   Each image may be JPEG, PNG, WebP or GIF and at most 300 KB.

### 2. Check it locally

```sh
node tools/serve.mjs ../my-ad            # then open http://localhost:8090/host/?package=/package/&format=midscroll
node tools/check.mjs ../my-ad --format midscroll
node tools/pack.mjs ../my-ad ../my-ad.zip
```

- **The simulator** runs the sites' own ad script. It logs every message between the page and your creative. If port 8090 is taken, add `--port 18090` and use that port in the address.
- **Check in the simulator** that:
  - `aeon:ready` arrives quickly;
  - the creative follows `aeon:scroll`, stops on `aeon:pause` and continues on `aeon:resume`;
  - every exit button sends the right exit. Clicks show a note instead of navigating.
- **Try:**
  - the phone window;
  - the dark page;
  - a resized window;
  - reduced motion, in the browser's developer tools: rendering → prefers-reduced-motion.
- **`check.mjs`** applies the server's rules with the server's messages. Fix every error it reports.
- **`pack.mjs`** writes the ZIP, after running the same check.

### 3. Upload the poster images

Upload each image to the WordPress media library and note its `id`:

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST \
  -H 'Content-Disposition: attachment; filename="vireo-mid.webp"' -H 'Content-Type: image/webp' \
  --data-binary @vireo-mid.webp "$SITE/wp-json/wp/v2/media"
# → {"id":861, …}
```

Set the alt text in the creative's `config.alt` (step 6).

### 4. Upload the package, with a dry run first

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -F 'file=@../my-ad.zip' "$API/packages?dry_run=1"
# → {"ok":true,"manifest":{…},"files":[…],"file_count":5,"bytes_unpacked":63221,"errors":[]}
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -F 'file=@../my-ad.zip' -F 'name=Vireo Home – midscroll, November' "$API/packages"
# → 201 {"id":"3f2a9c0d11e4b7a8", …}
```

- The package `id` is 16 hexadecimal characters.
- A dry run that says `"ok": false` lists the error. Fix it and pack again. Do not upload a package the dry run refuses.

### 5. Find the advertiser and create the ad as a draft

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" "$API/advertisers?per_page=100"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
  -d '{"title":"Vireo Home – midscroll, November","advertiser_id":33,"status":"draft"}' "$API/ads"
# → 201 {"id":1298,"revision":"1","stats_url":"https://…/ad-stats/…/", …}
```

Create a new advertiser (`POST advertisers {"name":"…"}`) only if the order says so.

### 6. Create the creative

`POST creatives` with:
- `ad_id`;
- `type` (the format);
- `config`;
- `destination_url` (the main landing page);
- `width` and `height` (the poster's size, for the record).

The examples below use made-up IDs.

**Midscroll**
```json
{"ad_id":1298,"type":"midscroll","destination_url":"https://vireo.example/","width":1920,"height":1080,
 "config":{"attachment_id":861,"mobile_id":862,"alt":"Vireo Home: breathe better at home.","advertiser":"Vireo Home",
   "width":"full","html5":"3f2a9c0d11e4b7a8","exits":{"main":"https://vireo.example/","product":"https://vireo.example/monitor"}}}
```

**Topscroll**
```json
{"ad_id":1299,"type":"topscroll","destination_url":"https://northline.example/","width":1920,"height":800,
 "config":{"attachment_id":859,"mobile_id":860,"alt":"Northline Rail: wake up somewhere new.","advertiser":"Northline Rail",
   "html5":"5b1e…","exits":{"main":"https://northline.example/","routes":"https://northline.example/routes"}}}
```

**Skin** (HTML5, with a 150 px band above the content)
```json
{"ad_id":1300,"type":"skin","destination_url":"https://vireo.example/","width":0,"height":0,
 "config":{"left_id":864,"right_id":865,"top_id":866,"top_height":150,"alt":"Vireo Home","advertiser":"Vireo Home",
   "html5":"9c0d…","exits":{"main":"https://vireo.example/","product":"https://vireo.example/monitor"}}}
```

**Reel** (HTML5; leave `cta` out when the creative has its own button)
```json
{"ad_id":1301,"type":"reel","destination_url":"https://hugh.example/recipe","width":1080,"height":1920,
 "config":{"attachment_id":867,"title":"Lemon garlic spaghetti in four steps","advertiser":"Hugh Cook",
   "slides":[{"attachment_id":868,"alt":"Step 1: boil the spaghetti."},{"attachment_id":869,"alt":"Step 2: garlic and chilli."},
             {"attachment_id":870,"alt":"Step 3: toss it together."},{"attachment_id":871,"alt":"Step 4: lemon, then eat."}],
   "html5":"7e44…","exits":{"main":"https://hugh.example/recipe"}}}
```

**Display** (a 300×600 HTML5 box)
```json
{"ad_id":1302,"type":"display","destination_url":"https://hugh.example/","width":300,"height":600,
 "config":{"attachment_id":863,"alt":"Hugh Cook: dinner in twenty minutes.","advertiser":"Hugh Cook",
   "html5":"a1b2…","exits":{"main":"https://hugh.example/","recipes":"https://hugh.example/recipes"}}}
```

**Rules:**
- Every exit in the manifest must have an address in `config.exits`; `main` is required.
- Without HTML5, leave out `html5` and `exits`. The images and `destination_url` are then the ad.
- Interstitial, layer, sticky, carousel and native take images only. See `CREATIVE-GUIDE.md`.

**Make it the ad's creative.** Keep the ad a draft:

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" "$API/ads/1298"            # read "revision"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X PATCH -H 'Content-Type: application/json' \
  -d '{"revision":1,"active_creative_id":1349}' "$API/ads/1298"
```

### 7. Make a preview link and send it

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST "$API/creatives/1349/preview-link"
# → {"url":"https://news.example.com/2026/…/?aeon_ad_preview=…","expires_at":"2026-11-07 09:08:04","page":"article"}
```

**What the link shows:**
- A real page on the site with your creative where the format belongs:
  - topscroll and skin on the front page;
  - midscroll, display and reel in the newest article, after paragraph 3.
- Every other ad from Ad Manager (and Advanced Ads) on that page is left out. Pictures that are part of the page's own content stay.
- Nothing is counted, and a click only shows where it would go.
- The link works for seven days.

**Before you send it:**
1. Open it yourself in a browser with a normal user agent (headless browsers that say so are refused). Automating clicks inside the creative's frame is unreliable; a person clicking each exit sees where it would go.
2. Check desktop and phone.
3. Send the link to the person who ordered the ad, and wait for their approval.

### 8. Replace the creative after feedback

- Make a new creative (step 6, with a new package or new images if needed) and make it active on the ad with `PATCH`. The old version keeps its history.
- Make a new preview link for the new creative.

### 9. After approval: put the ad on the site

**Look at the placements first:**
```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" "$API/placements/map"
```

Each placement has a `kind` and a `location`. `GET placements/<id>` also shows its `config.format`, the format the placement reserves room for.

| Kind | Where | Formats |
|---|---|---|
| `site_top` | top of every page | topscroll |
| `site_background` | the sides | skin |
| `content` | in the article text after a paragraph (`location.paragraph`) | midscroll, reel, display |
| `slot` | a widget area (`location.sidebar_name`) | display, reel |
| `overlay`, `anchor` | over the page, or along an edge | interstitial, layer, sticky |

Use the placement named in the order. If there is none for the format, ask the newspaper: creating a placement changes the site.

There are two ways to run the ad.

**A booking** is the usual way for a campaign with dates.
- Priority 1 (sponsorship) takes the placement alone for the period.
- Priority 2 shares it with the placement's group.

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
  -d '{"advertiser_id":33,"title":"Vireo Home – November","status":"active"}' "$API/campaigns"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
  -d '{"campaign_id":12,"title":"Midscroll","status":"active","start_at":"2026-11-02 23:00:00","end_at":"2026-11-16 23:00:00","priority":1,"pacing":"even"}' "$API/flights"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST -H 'Content-Type: application/json' -d '[1298]' "$API/flights/40/items"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X POST -H 'Content-Type: application/json' -d '[7]' "$API/flights/40/placements"
```

(Times are UTC: 23:00 UTC on 2 November is midnight in Sweden in winter.)

**A group** is the standing rotation of a placement.
1. Read the members.
2. Add the ad, with the weight the order gives.
3. Send **the whole list** back. A member you leave out is removed.

```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" "$API/groups/5/members"
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X PUT -H 'Content-Type: application/json' \
  -d '[{"ad_id":12,"weight":50,"position":0},{"ad_id":1298,"weight":50,"position":1}]' "$API/groups/5/members"
```

**Then make the ad active**, with a fresh revision:
```sh
curl -s --user "$WP_USER:$WP_APP_PASSWORD" -X PATCH -H 'Content-Type: application/json' \
  -d '{"revision":2,"status":"active"}' "$API/ads/1298"
```

To limit an ad to devices, create a condition set (`POST condition_sets {"json":{"type":"device","value":["mobile","tablet"]},"version":1}`) and set the ad's `condition_set_id`.

### 10. Check that it runs

- The next day, `GET placements/map` shows `served_7d` for the placement.
- `GET ads/<id>/report?from=YYYY-MM-DD&to=YYYY-MM-DD` gives impressions, clicks and the share seen on screen, per day.
- The ad's `stats_url` is a public statistics page that can be shared with the advertiser.

## Errors

| Status | Meaning | What to do |
|---|---|---|
| 400 | Something in the request is wrong; the message says what (for example `Reel packages must be responsive or 9:16`) | Fix it and send again once |
| 401 | Login failed | Check the user name and password; do not retry in a loop |
| 403 | Your role may not do this (settings, imports, code ads), or a preview link is not valid | Ask the newspaper |
| 404 | The ID does not exist | Read the list again |
| 409 | A stale `revision`, a creative change (creatives never change), or something in use | Read again; for a creative, make a new one |
| 410 | The preview link has expired | Make a new one |

## Delivery note

When you are done, report to the person who ordered the ad:
- the ad's ID and title;
- the creative's ID;
- the package ID;
- the preview link and the date it expires;
- the ad's statistics page (`stats_url`);
- anything you could not do.

Do not include the password.
