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.
- Kind
- Workflow
- Price
- Free
- Your time
- 10 minutes
- Approval
- Asks you first above its seat rule
- Deepens
- Run an experiment
Set up a Meta test of my four ad variants at $20 a day, spend up to $1, and show me the plan first.
curl -X POST "https: //api.sb/ leaseline.app/ campaigns/ launch? mode= try" \
-H "Authorization: Bearer $SB_TOKEN" \
-H "Content-Type: application/ json" \
-d '{"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"}}'
npx startup-builder do Campaign.launch --try --data '{"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"}}'
{
"name": "do",
"arguments": {
"mode": "try",
"code": "return await $.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' } })"
}
}
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' } })
{
"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"
}
}
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. · Campaign
campaign· The campaign on your ad account, and its id there.ads[]· Each Variant’s ad and the site’s review:approved,pendingorrejectedwith 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, Google Ads, 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.
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 | 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 | 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 | 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
Each failure, and what happens next
| 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 |
| Show these ads only to my verified list. | Integration.sync |
| Write the four Variants first, from the angles buyers use. | Ad.make |
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: and base(), or compose your own from these steps.
Works with
Plays that order it: Account ads, Buying committee, Creator ads, Paid search, Paid social, Promote winners, Retargeting, Sibling audiences.
It serves Run ads, and runs in Paid. It deepens Run an experiment.
For agents
For agents: the guide, its approvals, retries and lookups
| 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, |
| 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 |
|---|
| 1. Forecast clicks and cost on Google |
| 2. Check the conversion fires |
| 3. Sync the Audience to the ad account |
4. Create the campaign, the ad set and the ads Action class spend Seat rule that would hold it founder on spend, above its amount |
| 5. Let the stop rule pause it |
The amounts live once, on the hub (approvals).
Who does each step, and how it is checked
| 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, |
| 4. Create the campaign, the ad set and the ads | code | Code | Code reads the result back from where it landed | measured | $0.variants, |
| 5. Let the stop rule pause it | code | Code | Code reads the result back from where it landed | measured | $4.campaign, |
The doer is never the checker, and the weakest assurance on the path is the whole call’s.
Under the hood
Under the hood: the Systems, Nouns and Events it touches
| Kind | What it touches |
|---|---|
| Systems | Ads, Analytics and experiments |
| Nouns | Keyword, Event, Integration, Campaign, Mandate |
| Events | Event.captured, Integration.synced, Campaign.created, Mandate.authorized |
Reference
Inputs, outputs, its Event and its errors
| 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: | The most the campaign spends, on your ad account. It stops there. |
daily | Money | { amount: | 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 before it spends, and with the Data calls and Accounts groups (refusals).