<!--
Paid Landing Optimizer
Part of the Bles Software agent skills repository:
https://www.bles-software.com/claude-skills

Install for Claude Code:
  mkdir -p ~/.claude/skills/paid-landing-optimizer
  mv paid-landing-optimizer.md ~/.claude/skills/paid-landing-optimizer/SKILL.md

Install for Codex:
  mkdir -p ~/.codex/skills/paid-landing-optimizer
  mv paid-landing-optimizer.md ~/.codex/skills/paid-landing-optimizer/SKILL.md

For Gemini CLI and other agents, drop the body of this file into the
project instruction file the agent already reads.
-->

---
name: paid-landing-optimizer
description: Run a daily, evidence-first optimisation round on a landing page that is taking paid traffic. Use when asked to optimise, watch, or improve a page we are buying ads into (ChatGPT Ads, Google, Meta), when conversion or engagement on a funnel page is being reviewed, when a paid page needs analytics/session recording/heatmaps installed, or when the daily "Audit Funnel Daily Optimisation" job runs. Covers reading GA4 plus first-party telemetry, watching real session replays, running one honest A/B at a time, and shipping the change.
---

# Paid landing optimiser

For a page with a real, continuous ad budget behind it. The point is compounding
small verified wins, not a redesign. One change at a time, or nothing is
attributable.

## The live setup (2026-07-27)

| Thing | Where |
|---|---|
| Page | `https://www.bles-software.com` |
| Source | `~/bles-clutch`, `src/pages/FreeSoftwareAudit/` |
| Publish | `cd ~/bles-clutch && ./deploy.sh` — the ONLY supported path |
| Backend | `~/apps/bles-campaign-api`, port 9079, `your-api.example.com` |
| Backend release | `cd ~/apps/bles-campaign-api/current && ./scripts/release.sh` |
| Admin console | `https://bles-software.com/free-software-audit/admin?token=<REDACTED>` (also `your-api.example.com/admin`) |
| Admin token | `~/.bles-campaign-admin-token` (mode 600) |
| Evidence pack | `venv/bin/python scripts/audit_funnel_report.py --days 1 --markdown` |
| GA4 | property `493267513`, measurement `G-Y4ZX6EZ550`, via `scripts/ga4.py` |
| Daily job | `scripts/connect_audit_funnel_optimizer_automation.py` (08:12 Israel) |
| Ad account | OpenAI Ads, `api.ads.openai.com/v1`, key in `~/.config/teleclaudious/secrets/openai-ads.env` |
| Pixel | `L8fT29D2Wa92fmgDEhX3Dq`, live on the page (`oaiq.min.js`) |

## Delivery first, before any optimisation

Zero sessions has two causes that need opposite responses, and GA4 cannot tell
them apart: **nobody clicked** vs **nothing was served**. Read the `ADS:` line
at the top of the evidence pack before anything else.

Ads ran 15-19 July 2026 and then served **nothing for eight days**, invisibly,
while the page was being optimised against a dead campaign. That is exactly the
failure the ad-account read exists to prevent. Never ship a page change into a
campaign that is not delivering and call it an experiment.

### Connecting OpenAI Ads

Authentication is a **static bearer key, not OAuth** (`Authorization: Bearer`),
so there is no provider screen to redirect a human to. The connect link opens
our own paste page, which verifies the key against `GET /ad_account` live and
only stores it when OpenAI accepts it:

```
venv/bin/python -c "from telegram_bot.services import composio_connect_link as c, openai_ads_key as k; print(c.connect_url('telegram_6233944104', k.TOOLKIT, ttl=14*86400, path='/connect/openai-ads'))"
```

The key is created at `ads.openai.com/settings`, one key per ad account. Composio
has no OpenAI Ads toolkit (only `googleads` and `metaads`), so do not look for one.

**The same key does two jobs.** Besides reads, it mints the Conversions API key
(`POST /conversions/api_keys`) and registers the lead event
(`POST /conversions/event_settings`). Without that, OpenAI Ads gets no
server-side conversion signal and can only optimise toward clicks, not leads.
Do that setup the moment the key lands, before any page work.

### The read endpoints that matter

`GET /ad_account`, `GET /campaigns`, and
`GET /ad_account/insights?time_granularity=daily&aggregation_level=campaign`
with `time_ranges[]={"type":"date_range","since":"…","until":"…"}` and repeated
`fields[]`. There is **no** flat `/insights?level=` endpoint; that was wrong in
the first write client. `scripts/audit_funnel_report.py` already joins all three
into the daily pack and degrades to a visible reason instead of failing.

Two things that cost real time on 2026-07-27, both now handled in that script:

- **`until` can never be today.** The API answers
  `400: time_ranges.end cannot be in the future` for any window whose last day
  has not closed in the account timezone, and one rejected call used to blank the
  whole ads section, so a working key looked like a broken one. Always ask for
  `min(end, yesterday)` and report the day you actually read through. Today's
  spend is simply not readable until the day closes.
- **Creative level is where the signal is.** Pass
  `aggregation_level=ad` with `fields[]=ad.id&fields[]=ad.name` for per-ad
  impressions, clicks, CTR, CPC and spend. Campaign level hides it: in July the
  four ads ran 0.44% to 1.13% CTR, a 2.6x spread, and one ad took 67% of the
  clicks and 68% of the spend. Ads are only listable per ad group and ad groups
  only per campaign, so walk `/campaigns` → `/ad_groups?campaign_id=` →
  `/ads?ad_group_id=` to get each creative's title, body and `target_url`. The
  `utm_content` in that URL is the join between an ad and the landing headline it
  paid for: that is how you check message match, and it is how a mismatch was
  found (see Traps).

The CMS backend at `bles-software-backend.onrender.com` is on an account we do
NOT control. Never build anything that depends on it. Lead capture posts to our
own campaign-api independently of it, on purpose.

## The loop

1. **Evidence, not intuition.** Run the report for `--days 1` and `--days 7`.
2. **Watch real people.** Open the admin console and play two or three session
   replays, preferring sessions that left without starting the form, and any
   session with rage clicks. Read the click heatmap and scroll reach. Name the
   single most expensive drop-off you can actually *see*.
3. **Check whether the budget can even settle a split test.** The report prints
   how many days a 50/50 split needs to fill both arms at the current cap and CPC.
   Over about a week, do not split at all: keep everyone on one baseline, improve
   that, and compare week over week. Splitting traffic the budget cannot resolve
   buys no verdict and sends half of it to an unproven page. When a split *is*
   affordable, the verdict rules apply:
   - `not_enough_data` → observe only. Do not stack a second change on a running test.
   - `winner` → promote the winner to baseline, then start ONE new variant B.
   - `tie` → kill B, keep baseline, start a different ONE.
4. **One change at a time.** Write the hypothesis and the metric you expect to
   move BEFORE building it. On a single baseline that change goes to everyone.
5. **Ship and verify in the browser** at 390px and 1440px. Confirm the change is
   visible and telemetry still records a session afterwards.
6. **Log the round** with `--log-change`.
7. **Report in Hebrew, short, human.** No code, paths, logs or JSON.

## Metrics: which number to steer on

`formStartRate` (form starts / sessions) is the **fast** metric: it moves within a
day at 20-40 sessions. `leadRate` is the **true north** but far too slow to steer
a daily loop. Steer on form start rate, report both, and only ever call a winner
on `leadRate` over weeks.

**Never promote a variant on fewer than 60 sessions per arm.** That bar does not
move with the budget, the wait does, so read the real burn out of the ad account
instead of assuming it. This page was documented at 50 ILS/day, spent $112 and
$125 on its two full delivery days in July (about 36 clicks a day at a measured
$3.34 CPC), and has run a $25/day cap since 2026-07-27, which is about 7 clicks a
day and roughly 16 days to fill two arms. `audit_funnel_report.py` enforces the
bar, returns `not_enough_data`, and prints the wait; respect both. Same-day
"winners" at this volume are noise.

**Read which budget shape is set before saying anything about runway.** Both
exist and they fail in opposite ways:

- `budget.daily_spend_limit_micros` — a per-day cap. It never runs out, so with
  no `end_time` the campaign spends `cap × 30.4` a month until someone stops it.
  The risk is silent open-ended spend, and the useful check is pacing: a day that
  spends well under the cap means the bid or the audience is the limit that day,
  not the page.
- `budget.lifetime_spend_limit_micros` — cumulative across the campaign's whole
  life, so a restarted campaign resumes against what it already spent: in July a
  $550 cap had $240 gone, leaving under three days of runway. A campaign that
  hits it goes quiet while still reading `status: active`, which is exactly the
  failure the delivery check exists for.

Reading only one field reports `$0` on a live campaign that uses the other.

**Zero impressions is not always an outage.** Insights only cover closed days in
the account timezone, so a campaign that (re)started today has no numbers yet.
Compare `start_time` against the last closed reporting day before raising an
alarm, or the round spends itself hunting a failure that never happened.

## What the telemetry gives you

Captured first-party, no third-party recorder:

- clicks and **rage clicks** (3+ rapid clicks in one spot) as page-fraction coords,
  so the heatmap overlays the live page
- scroll depth milestones and max reach
- engaged time (only while the tab is visible)
- **which form field they touched last before leaving** — the single most useful
  field on the whole dashboard
- masked rrweb session replay (inputs are masked; you see layout and behaviour,
  never what anyone typed)
- IP enriched to country/region/city/network, then discarded. Only a daily-salted
  hash is stored.
- sticky per-visitor A/B variant, forceable with `?v=a` / `?v=b`

## Traps that already cost a day

- **`sendBeacon` and `fetch(keepalive)` silently drop bodies over ~64KB.** An rrweb
  snapshot is bigger than that. The first version shipped sending nothing while the
  browser cheerfully reported the request. Small payloads take the beacon path,
  large ones a plain `fetch`. Verify against the nginx access log, not the
  browser's request list — a request that was *initiated* proves nothing.
- **Cloudflare 403 error 1010** on `campaign-api` = default `Python-urllib` agent.
  Send a real `User-Agent`.
- **`shareables.clutch.co` badge iframes** return a Cloudflare interstitial from
  outside a browser session and render as blank boxes. Use text, not those iframes.
- **`public/best-award-*.svg` are generic stock clipart** (one literally says "YOUR
  TEXT HERE"). They are not real awards. Never use them as proof.
- Proof must be verifiable. Clutch: 4.9/5 from 9 reviews, checked 2026-07-27 at
  `clutch.co/profile/bles-software`. Re-check before restating it.
- The backend runs from `releases/<sha>-pinned`, **not** from `current/`. Editing
  `current/` changes nothing until `./scripts/release.sh` runs.
- **A `utm_content` alias table drifts silently and nothing fails.** In July
  `operator_clarity` (48 of 72 clicks, $162 of $240) was aliased to the internal
  tools headline while its ad promised "connect tools, remove handoffs", and
  `operations_momentum` had the reverse: the two busiest ads were mapped to each
  other's copy. Two more ads had no alias at all and silently fell back to the
  default. Everything rendered, every test passed, and two thirds of paid clicks
  landed on an off-message headline. Whenever a creative changes, re-read the ad
  copy out of the account and check the alias table against it. The regression
  test to keep is the one that asserts each live `utm_content` resolves to copy
  containing that ad's own promise word, not one that pins an exact mapping.

## Changing the ads themselves

Everything is writable from the `openai-ads` MCP (29 tools). Two API shapes bite
on the first write and both are fixed in `scripts/openai_ads_mcp.py`, so they only
matter when calling the API directly:

- **Every nested object is replaced whole, never merged.** A bid-only
  `POST /ad_groups/{id}` 400s on `bidding_config.billing_event_type`, and a
  headline-only `POST /ads/{id}` 400s on `creative.title`. Read the entity first
  and overlay the one field. Never fill the missing field with a guess:
  defaulting `billing_event_type` to `click` would silently move an
  impression-billed group onto clicks.
- `creative.image_url` comes back on a read and is a short-lived signed mirror of
  `file_id`. Strip it before writing it back. It is also the only way to actually
  *look* at a live creative: there is no file read endpoint, so `get_ad` →
  download the signed URL → view it.
- **Look at the image before pairing copy with it.** These creatives have the
  headline baked into the picture ("API INTEGRATION / CONNECT TOOLS. REMOVE
  HANDOFFS.", "SOFTWARE BOTTLENECK / FIND IT. FIX IT."), so a reused image can
  contradict the new copy, or duplicate a sibling ad's headline.
- New ads land `review_status: in_review` and serve nothing until OpenAI approves,
  so the clock on a creative test starts at approval, not at creation.
- Hold the arm count flat. Replacing two dead ads with two new ones keeps the
  per-ad sample the same; adding a fifth arm on a $25/day cap just slows every
  verdict.

## Evidence base for what to try

Measured on this page 2026-07-16..19 (149 paid ChatGPT sessions): 87% of sessions
under 10 seconds, 83 people saw the six-field form, **2 started it**, 2 leads.

From current CRO research, in rough order of expected effect:

- **Form length dominates.** 3-field forms convert ~25%, 7-field ~12%. Multi-step
  with a progress bar cuts abandonment sharply versus one long form.
- **Video above the fold** lifts conversion materially; short beats long by a lot.
- **Specificity beats polish for AI-ad traffic.** ChatGPT ad clicks arrive with a
  conversational, problem-aware frame. Generic cold-traffic pages underperform;
  pages that mirror the question the user just asked do much better.
- Proof next to the button, not in a separate section.
- Mobile is ~37% of this traffic and engages for ~2.5s. The ask must be reachable
  without scrolling past a film and three paragraphs.

Sources: [form field benchmarks](https://www.digitalapplied.com/blog/form-conversion-rate-benchmarks-2026-data-points),
[landing page conversion benchmarks](https://firstpagesage.com/seo-blog/b2b-landing-page-conversion-rates/),
[ChatGPT Ads landing pages](https://www.adventureppc.com/blog/how-to-build-high-converting-landing-pages-for-chatgpt-ads-traffic-in-2026),
[video conversion statistics](https://levitatemedia.com/learn/video-conversion-statistics).

## Adding this to another page

1. `siteTelemetry.start({ variant: resolvePageVariant() })` in the page's effect,
   and `siteTelemetry.track("form_start" | "form_submit" | …)` at the funnel stages.
2. Add the page path to the report's `PAGE_PATH`, or parameterise it.
3. Register a daily job with the `connect_*_automation.py` pattern.
Nothing else is page-specific.
