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

# Message test

> Four angles in your buyers’ words run as ads on $100 of your spend, and the winner named by a rule set before any spend.

Four angles in your buyers’ words run as ads on $100 of your spend, and the winner named by a rule set before any spend.

```text title="Run it · Ask"
Find out which message gets small landlords to start a trial, put up to $100 into ads, spend up to $5 on the work, and show me the plan first.
```

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

const result = await $.try.Experiment.run({ goal: 'Find out which message gets small landlords to start a trial, put up to $100 into ads, spend up to $5 on the work, and show me the plan first.', budget: { amount: 5, currency: 'USD' } })
```

**Plan.** (1) Read “small landlords” from the offer’s ICP: 20 to 400 units, owners and managers. “Start a trial” is the `Offer.trialed` Event, checked to fire before any spend. (2) Choose the site: Meta, as a Decision. Its buyers are targetable by job and interest, the whole setup runs through the API at $0, and search volume for these terms is thin. (3) Read buyers’ words: `Keyword.search` for problem posts, 12 searches, about 2¢. (4) Write 4 angles and their copy, about 5¢; make 8 images, about 10¢; check claims and ad policy, about 2¢. (5) Launch on Meta at $25 a day, split evenly; read results daily from your own account, free. (6) Declare the rules first. Winner: the most trials per dollar, at least 3; ties go to the lower cost per click. Early pause: $15 spent, 0 trials and a click-through under half the leader’s. (7) Write the readout, about 1¢. Expected: about 25¢ of the $5 budget, plus the $100 of spend, over 4 to 5 days. Estimate 25¢ of $5.00.

**Receipt.** 12 calls, 28¢ spent, 1 of 1 winning message, likely.

- **Kind:** Agent task
- **Price:** 18¢ per 4 angles
- **Your time:** 20 minutes
- **Approval:** Asks you first above its seat rule
- **Deepens:** [`workflow:run-experiment`](https://startupbuilder.dev/workflows/processes/experimentation/run-experiment)

## What you get
- **4 angles** · Four angles in your buyers’ words run as ads on $100 of your spend, and the winner named by a rule set before any spend. · [`noun:experiment`](https://startupbuilder.dev/business/nouns/experiment)
- `angles[]` · Each Variant: the angle, the buyer’s words it came from, spend, clicks and results.
- `winner` · The Variant the winner rule names, or `null` when none reaches its minimum: never a forced winner.
- `decision` · `ship`, `kill` or `iterate`, with the reason and how sure: `likely` under 10 results, `firm` past it.
- `readout` · Two sentences you can act on: which angle to lead with on the home page and in cold email.

**Connect once:** [Meta Ads](/code/integrations/meta-ads). **How long:** Waits 3 to 7 days for $100 to spend at $20 to $30 a day.

## Trigger
It runs when you call it, and from a Demand Budget’s $100 tier under every strategy.

```ts
on.PlayMix.compiled((mix, $) => mix.dial.includes('smoke-test') && $.Experiment.run({ offer: 'leaseline-pro', spend: { amount: 100, currency: 'USD' }, venue: 'meta' }))
```

## 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. Read buyers’ words in problem posts | [`Keyword.search`](/code/data/keywords/search) | The phrases buyers use about the problem, ranked by how often they recur | About 1.9¢ | Runs on its own |
| 2. Choose the angles | `Content.draft` | Four angles, each traced to a buyer phrase and the offer’s promise | About 1¢ | Runs on its own |
| 3. Write each Variant | `Content.draft` | Headline and primary text per angle, at the site’s lengths | About 4¢ | Runs on its own |
| 4. Make two images per Variant | `File.generate` | Eight images at the site’s feed and story sizes | About 10¢ | Runs on its own |
| 5. Start the Experiment with its rules | `Experiment.start` | The Experiment, with the winner and pause rules fixed before any spend | Free | Runs on its own |
| 6. Launch the campaign | [`Campaign.launch`](/capabilities/run-ads/ad-test-setup) | An even split on your ad account, the conversion checked first | Free | Asks you first above its seat rule |
| 7. Read results daily, pause by the floor | [`Integration.call`](/code/integrations/calls/call) | Spend, clicks and results per Variant; a Variant under the floor is paused and its share moved | Free | Runs on its own |
| 8. Conclude by the winner rule | `Experiment.conclude` | `ship`, `kill` or `iterate`, with the reason | Free | Runs on its own |
| 9. Write the readout | `Content.draft` | What won, by how much, how sure, and where to use the angle next | About 1¢ | Runs on its own |

Calls: `Keyword.search`, then `Content.draft`, then `File.generate`, then `Experiment.start`, then `Campaign.launch`, then `Integration.call`, then `Experiment.conclude`, then `Content.draft`.

About 18¢ per 4 angles, each time it runs, capped at $1.00.

## Example: give it a goal
> Find out which message gets small landlords to start a trial, put up to $100 into ads, spend up to $5 on the work, and show me the plan first.

Run it above shows the plan it showed before it spent anything, and the receipt of every call.

## When something fails

| When | Then |
| --- | --- |
| No Variant reaches the winner rule’s minimum | It ends `iterate` with the counts; it never forces a winner. |
| The site’s ad review rejects a Variant | It is rewritten and reviewed again at no charge, and rejoins the split. |
| The read is close when `spend` runs out | It opens an approval with the projection, such as “$50 more makes the read firm”; your answer decides, and a refusal stands as `likely`. |
| You call it twice for the same offer and site | The open Experiment is returned: it is idempotent per offer, site and angles. |

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-experiment-run-1-<step>-<item>`.

## How it is graded
A second model grades the setup before any spend and the readout after. It passes when every angle traces to a buyer phrase, every Variant claims only what the offer does and promises no result, and the readout names the winner the declared rule names, with its count. A miss is redone at no charge.

## Change it
| Ask | Compiles to |
| --- | --- |
| Run it on LinkedIn to operations titles at $30 a day. | [`Experiment.run`](/capabilities/run-ads/message-test) |
| Spend $50 more if the leader is ahead but not firm. | `Limit.raise` |
| Make an ad kit for the winning angle. | [`Ad.make`](/capabilities/make-content/ad-kit) |

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

export default workflow({
  name: 'experiment-run',
  verb: 'Experiment.run',
  input: { offer: 'string', spend: 'Money', venue: "'meta' | 'google' | 'linkedin' | 'reddit'", angles: 'integer', metric: 'string' },
  maxCost: 1,
  grade: { evaluator: 'setup before spend; readout after', passWhen: 'angles trace to buyer words; no claim past the Offer; the rule names the winner', remedy: 'redo' },
  execute: async (ctx, { offer, spend, venue = 'meta', angles = 4, metric = 'Offer.trialed' }) => {
    const words = await ctx.step('words', () => $.Keyword.search({ keywords: problemOf(offer), venues: ['reddit'], kind: 'problem-question', maxCost: 0.03 }))
    const variants = await ctx.step('variants', () => $.Content.draft({ kind: 'ad-variants', from: words, offer, count: angles, images: 2, maxCost: 0.2 }))
    const exp = await ctx.step('start', () => $.Experiment.start({ variants, metric, winnerRule: 'most per $, min 3', pauseRule: '$15, 0, CTR < half the leader' }))
    await ctx.step('launch', () => $.Campaign.launch({ venue, variants: variants.map((v) => v.$id), spend, daily: { amount: 25, currency: 'USD' }, conversion: metric }))
    await ctx.waitForEvent('Campaign.spent', { where: `campaign = '${exp.campaign}' and total >= ${spend.amount}`, timeout: '10d' })
    return ctx.step('conclude', () => $.Experiment.conclude({ experiment: exp.$id }))    // the declared winner rule decides
  },
})
```

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

## Works with
**Plays that order it:** [Creator ads](/plays/creator-ads), [Test with a creator](/plays/creator-probe), [Paid search](/plays/paid-search), [Paid social](/plays/paid-social), [Positioning tournament](/plays/positioning-tournament), [Promote winners](/plays/promote-winners), [Video ladder](/plays/video-ladder).

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 | `Experiment.run` |
| Guide approval | `limits` |
| Goal | Find out which message gets small landlords to start a trial, put up to $100 into ads, spend up to $5 on the work, 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.PlayMix.compiled((mix, $) => mix.dial.includes('smoke-test') && $.Experiment.run({ offer: 'leaseline-pro', spend: { amount: 100, currency: 'USD' }, venue: 'meta' }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-experiment-run-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Read buyers’ words in problem posts | None | None. |
| 2. Choose the angles | None | None. |
| 3. Write each Variant | None | None. |
| 4. Make two images per Variant | None | None. |
| 5. Start the Experiment with its rules | None | None. |
| 6. Launch the campaign | None | None. |
| 7. Read results daily, pause by the floor | None | None. |
| 8. Conclude by the winner rule | None | None. |
| 9. Write the readout | None | None. |

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

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Read buyers’ words in problem posts | `code` | Code | Code checks each result against its declared type | `measured` | `$0.offer.problem` |
| 2. Choose the angles | `agentic` | The [Paid media](/capabilities/agents/paid-media) agent | A second model grades the output | `graded` | `$1.phrases, $0.offer, $0.angles` |
| 3. Write each Variant | `agentic` | The [Paid media](/capabilities/agents/paid-media) agent | A second model grades the output | `graded` | `$2.angles, $0.venue` |
| 4. Make two images per Variant | `code` | Code | Adversarial review by a second model: Pass, or the cut claim, such as an image that implies a guaranteed result | `measured` | `$3.variants` |
| 5. Start the Experiment with its rules | `code` | Code | Code reads the result back from where it landed | `measured` | `$4.variants, $0.winnerRule, $0.pauseRule` |
| 6. Launch the campaign | `code` | Code | Code reads the result back from where it landed | `measured` | `$5, $0.spend, $0.venue` |
| 7. Read results daily, pause by the floor | `code` | Code | Code checks each result against its declared type | `measured` | `$6.campaign` |
| 8. Conclude by the winner rule | `code` | Code | Code reads the result back from where it landed | `measured` | `$7, $0.winnerRule` |
| 9. Write the readout | `agentic` | The [Paid media](/capabilities/agents/paid-media) agent | A second model grades the output | `graded` | `$8` |

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 | [Analytics and experiments](/code/apps/analytics-and-experiments), [Media](/code/apps/media), [sites and content](/code/apps/sites-and-content) |
| Nouns | [`noun:keyword`](https://startupbuilder.dev/business/nouns/keyword), [`noun:content`](https://startupbuilder.dev/business/nouns/content), [`noun:file`](https://startupbuilder.dev/business/nouns/file), [`noun:action`](https://startupbuilder.dev/business/nouns/action), [`noun:experiment`](https://startupbuilder.dev/business/nouns/experiment), [`noun:campaign`](https://startupbuilder.dev/business/nouns/campaign), [`noun:integration`](https://startupbuilder.dev/business/nouns/integration) |
| Events | `Content.drafted`, `File.generated`, `Action.evaluated`, `Experiment.started`, `Integration.called`, `Experiment.concluded` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `offer` | `Offer` | Required | The offer the ads sell. |
| `spend` | `Money` | `{ amount: 100, currency: "USD" }` | Ad money on your account. $100 at $20 to $30 a day reads four angles in 3 to 7 days; the Paid strategy uses $300 to $500. |
| `venue` | `'meta' \| 'google' \| 'linkedin' \| 'reddit'` | `'meta'` | Where the test runs. Meta is the default for a new product: buyers are targetable by job and interest, and search volume is thin. |
| `angles` | `integer` | `4` | Angles tested. Each gets an equal share until the early-pause floor fires. |
| `metric` | `string` | `'Offer.trialed'` | The Event that counts as a result. |
| `winnerRule` | `string` | `'most results per dollar, at least 3; ties go to the lower cost per click'` | Declared before the first dollar runs, so the winner is a rule, not a reading. |
| `pauseRule` | `string` | `'$15 spent, 0 results, and a click-through under half the leader’s'` | The early-pause floor: a Variant under it stops and its share moves to the leaders. |
| `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 `Experiment.concluded`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).