Skip to content

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
Campaign.launch
You, to your agent

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' } })
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"
  }
}
Try mode reads, stores nothing and spends nothingAPI referenceIts Nouns

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, 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, 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.

TypeScript
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.

StepVerbReturnsPriceApproval
1. Forecast clicks and cost on GoogleKeyword.forecastClicks and cost a day at your cap; skipped on Meta, which needs no keywordsFreeRuns on its own
2. Check the conversion firesEvent.captureA test Event on your signup page, read back; no campaign is created until it firesFreeRuns on its own
3. Sync the Audience to the ad accountIntegration.syncYour Audience as a custom audience on the siteFreeRuns on its own
4. Create the campaign, the ad set and the adsCampaign.createOne campaign, one ad set, an ad per Variant, split evenly at the daily capFreeAsks you first above its seat rule
5. Let the stop rule pause itMandate.authorizeA standing approval scoped to pausing this campaign, so the stop rule acts without askingFreeRuns 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
WhenThen
The conversion Event does not fireIt stops before anything is created, with the fix “Add the trial Event to /signup”; nothing is billed.
The site rejects an adThe 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 fundsThe campaign waits on the site (awaitingSource) and you are told once.
You call it twice with the same Variants on one dayThe 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

AskCompiles 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
TypeScript
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 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
FactValue
GuideCampaign.launch
Guide approvalmandate
GoalSet 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.
Triggeron.Experiment.started((experiment, $) => $.Campaign.launch({ venue: 'meta', variants: experiment.variants, spend: experiment.spend }))
RetryThree times with backoff, then the next route; the same idempotency key never charges, sends or posts twice
Idempotency keyleaseline-campaign-launch-1
LookupsA 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
StepFunctionDoerCheckerAssuranceInputs
1. Forecast clicks and cost on GooglecodeCodeCode checks each result against its declared typemeasured$0.variants.keywords
2. Check the conversion firescodeCodeCode checks each result against its declared typemeasured$0.conversion
3. Sync the Audience to the ad accountcodeCodeCode reads the result back from where it landedmeasured$0.audience, $0.venue
4. Create the campaign, the ad set and the adscodeCodeCode reads the result back from where it landedmeasured$0.variants, $0.daily, $3
5. Let the stop rule pause itcodeCodeCode reads the result back from where it landedmeasured$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

Under the hood: the Systems, Nouns and Events it touches
KindWhat it touches
SystemsAds, Analytics and experiments
NounsKeyword, Event, Integration, Campaign, Mandate
EventsEvent.captured, Integration.synced, Campaign.created, Mandate.authorized

Reference

Inputs, outputs, its Event and its errors
NameTypeDefaultMeaning
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.
variantsVariant[]RequiredTwo to four Variants, from Ad kit or your own.
audienceAudienceRequiredOptional: a saved Audience synced to the site, such as your verified list.
spendMoney{ amount: 100, currency: "USD" }The most the campaign spends, on your ad account. It stops there.
dailyMoney{ amount: 20, currency: "USD" }The daily cap, split evenly across the Variants.
conversionstring'Offer.trialed'The Event that counts as a result. It must fire before the campaign is created.
maxCostnumber1.00The 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).

Contents