URL: /capabilities/run-ads/ad-test-setup · Id: `page:/capabilities/run-ads/ad-test-setup`

# Ad test setup

> Your ad Variants in, one campaign out on your own ad account, split evenly, with the conversion checked and the stop rule set first.

Your ad [Variants](https://startupbuilder.dev/business/nouns/variant) in, one campaign out on your own ad account, split evenly, with the conversion checked and the stop rule set first.

```text title="Run it · Ask"
Set up a Meta test of my four ad variants at $20 a day, spend up to $1, and show me the plan first.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Campaign.launch({ goal: 'Set up a Meta test of my four ad variants at $20 a day, spend up to $1, and show me the plan first.', budget: { amount: 1, currency: 'USD' } })
```

```json title="Run it · Response"
{
  "mode": "try",
  "stored": false,
  "estimate": "Free",
  "sample": {
    "venue": "Meta",
    "variants": "4",
    "approved": "3",
    "rejected": "v_3: implied a guaranteed result",
    "daily": "$20",
    "stops at": "$100 spent"
  }
}
```

- **Kind:** Workflow
- **Price:** Free
- **Your time:** 10 minutes
- **Approval:** Asks you first above its seat rule
- **Deepens:** [`workflow:run-experiment`](https://startupbuilder.dev/workflows/processes/experimentation/run-experiment)

## What you get
- **1 campaign** · Your ad Variants in, one campaign out on your own ad account, split evenly, with the conversion checked and the stop rule set first. · [`noun:campaign`](https://startupbuilder.dev/business/nouns/campaign)
- `campaign` · The campaign on your ad account, and its id there.
- `ads[]` · Each Variant’s ad and the site’s review: `approved`, `pending` or `rejected` with the site’s reason.
- `conversionFires` · Whether the conversion Event fired on a test before anything was created.
- `mandate` · The standing approval that lets the stop rule pause the campaign without asking you.

**Connect once:** [Meta Ads](/code/integrations/meta-ads), [Google Ads](/code/integrations/google-ads), [LinkedIn Ads](/code/integrations/linkedin-ads). **How long:** About 2 minutes, then the site’s ad review, usually under 24 hours on Meta.

## Trigger
It runs when you call it, and as the launch step of message test.

```ts
on.Experiment.started((experiment, $) => $.Campaign.launch({ venue: 'meta', variants: experiment.variants, spend: experiment.spend }))
```

## Steps
Your agent does these steps, one row per step; each step’s check is part of its row and its price.

| Step | Verb | Returns | Price | Approval |
| --- | --- | --- | --- | --- |
| 1. Forecast clicks and cost on Google | [`Keyword.forecast`](/code/data/keywords/forecast) | Clicks and cost a day at your cap; skipped on Meta, which needs no keywords | Free | Runs on its own |
| 2. Check the conversion fires | `Event.capture` | A test Event on your signup page, read back; no campaign is created until it fires | Free | Runs on its own |
| 3. Sync the Audience to the ad account | `Integration.sync` | Your Audience as a custom audience on the site | Free | Runs on its own |
| 4. Create the campaign, the ad set and the ads | [`Campaign.create`](/code/apps/ads/create-campaign) | One campaign, one ad set, an ad per Variant, split evenly at the daily cap | Free | Asks you first above its seat rule |
| 5. Let the stop rule pause it | [`Mandate.authorize`](/code/integrations/calls/authorize) | A standing approval scoped to pausing this campaign, so the stop rule acts without asking | Free | Runs on its own |

Calls: `Keyword.forecast`, then `Event.capture`, then `Integration.sync`, then `Campaign.create`, then `Mandate.authorize`.

Free, each time it runs, capped at $1.00.

## Example: give it a goal
> Set up a Meta test of my four ad variants at $20 a day, spend up to $1, and show me the plan first.

The plan it shows runs 5 steps, priced before it spends anything; Run it above shows the result it returns.

## When something fails

| When | Then |
| --- | --- |
| The conversion Event does not fire | It stops before anything is created, with the fix “Add the trial Event to /signup”; nothing is billed. |
| The site rejects an ad | The site’s reason is recorded and the Variant is handed back to Ad kit; the others run on. |
| The ad account is paused or out of funds | The campaign waits on the site (`awaitingSource`) and you are told once. |
| You call it twice with the same Variants on one day | The same campaign is returned: it is idempotent per site, Variants and day. |

Each step retries three times, then falls through to the next route; one item's failure lands in `skipped` with its reason, never failing the call. Before a step would cross `maxCost` it stops and returns what it has, with a `resume` link. Each step's idempotency key is the call's key plus the step and the item: `leaseline-campaign-launch-1-<step>-<item>`.

## Change it
| Ask | Compiles to |
| --- | --- |
| Run it on LinkedIn instead, at $30 a day. | [`Campaign.launch`](/capabilities/run-ads/ad-test-setup) |
| Show these ads only to my verified list. | `Integration.sync` |
| Write the four Variants first, from the angles buyers use. | [`Ad.make`](/capabilities/make-content/ad-kit) |

```ts
import { $, workflow } from 'startup-builder'

export default workflow({
  name: 'campaign-launch',
  verb: 'Campaign.launch',
  input: { venue: "'meta' | 'google' | 'linkedin' | 'reddit' | 'x'", variants: 'string[]', audience: 'string?', spend: 'Money', daily: 'Money', conversion: 'string' },
  maxCost: 1,
  execute: async (ctx, { venue = 'meta', variants, audience, spend, daily, conversion = 'Offer.trialed' }) => {
    const fires = await ctx.step('conversion', () => $.Event.capture({ name: conversion, test: true }))
    if (!fires.ok) return ctx.refuse('conversion-not-firing', `Add the ${conversion} Event to your signup page`)
    if (audience) await ctx.step('audience', () => $.Integration.sync({ integration: `${venue}-ads`, audience }))
    const c = await ctx.step('create', () => $.Campaign.create({ venue, variants, audience, budget: { ...daily, per: 'day' }, cap: spend }))
    const m = await ctx.step('mandate', () => $.Mandate.authorize({ verbs: ['Campaign.update'], on: c.$id, topics: ['pause'] }))
    return { campaign: c.$id, ads: c.ads, conversionFires: true, mandate: m.$id }   // no winner is picked here
  },
})
```

Extend it with `extends: 'Campaign.launch'` and `base()`, or [compose your own](/code/compose) from these steps.

## Works with
**Plays that order it:** [Account ads](/plays/account-ads), [Buying committee](/plays/buying-committee), [Creator ads](/plays/creator-ads), [Paid search](/plays/paid-search), [Paid social](/plays/paid-social), [Promote winners](/plays/promote-winners), [Retargeting](/plays/retargeting), [Sibling audiences](/plays/sibling-audiences).

It serves [Run ads](/capabilities/run-ads), and runs in [Paid](/strategy/paid). It deepens [`workflow:run-experiment`](https://startupbuilder.dev/workflows/processes/experimentation/run-experiment).

## For agents

| Fact | Value |
| --- | --- |
| Guide | `Campaign.launch` |
| Guide approval | `mandate` |
| Goal | Set up a Meta test of my four ad variants at $20 a day, spend up to $1, and show me the plan first. It may change counts, the order of steps, soft filters and the route a read takes, and it stops early when the goal is met. It never relaxes your words, raises its own budget, answers its own approval, calls a Play or acts outside your standing approval. |
| Trigger | `on.Experiment.started((experiment, $) => $.Campaign.launch({ venue: 'meta', variants: experiment.variants, spend: experiment.spend }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-campaign-launch-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Forecast clicks and cost on Google | None | None. |
| 2. Check the conversion fires | None | None. |
| 3. Sync the Audience to the ad account | None | None. |
| 4. Create the campaign, the ad set and the ads | `spend` | `founder` on `spend`, above its amount |
| 5. Let the stop rule pause it | None | None. |

The amounts live once, on the hub ([approvals](https://startupbuilder.dev/agents/approvals)).

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Forecast clicks and cost on Google | `code` | Code | Code checks each result against its declared type | `measured` | `$0.variants.keywords` |
| 2. Check the conversion fires | `code` | Code | Code checks each result against its declared type | `measured` | `$0.conversion` |
| 3. Sync the Audience to the ad account | `code` | Code | Code reads the result back from where it landed | `measured` | `$0.audience, $0.venue` |
| 4. Create the campaign, the ad set and the ads | `code` | Code | Code reads the result back from where it landed | `measured` | `$0.variants, $0.daily, $3` |
| 5. Let the stop rule pause it | `code` | Code | Code reads the result back from where it landed | `measured` | `$4.campaign, $0.spend` |

The doer is never the checker, and the weakest assurance on the path is the whole call’s.

## Under the hood

| Kind | What it touches |
| --- | --- |
| Systems | [Ads](/code/apps/ads), [Analytics and experiments](/code/apps/analytics-and-experiments) |
| Nouns | [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword), [`noun:event`](https://startupbuilder.dev/business/nouns/event), [`noun:integration`](https://startupbuilder.dev/business/nouns/integration), [`noun:campaign`](https://startupbuilder.dev/business/nouns/campaign), [`noun:mandate`](https://startupbuilder.dev/business/nouns/mandate) |
| Events | `Event.captured`, `Integration.synced`, `Campaign.created`, `Mandate.authorized` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `venue` | `'meta' \| 'google' \| 'linkedin' \| 'reddit' \| 'x'` | `'meta'` | The ad account. Meta runs end to end through its API; the others set up through the browser agent signed in as you. |
| `variants` | `Variant[]` | Required | Two to four Variants, from Ad kit or your own. |
| `audience` | `Audience` | Required | Optional: a saved Audience synced to the site, such as your verified list. |
| `spend` | `Money` | `{ amount: 100, currency: "USD" }` | The most the campaign spends, on your ad account. It stops there. |
| `daily` | `Money` | `{ amount: 20, currency: "USD" }` | The daily cap, split evenly across the Variants. |
| `conversion` | `string` | `'Offer.trialed'` | The Event that counts as a result. It must fire before the campaign is created. |
| `maxCost` | `number` | `1.00` | The most this call spends each time it runs, in USD. It stops before a step would cross it and returns what it has, with a `resume` link. |

Emits `Campaign.launched`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).