URL: /plays/paid-search · Id: `play:paid-search`

# Paid search

> Bids on searches that show buying intent, and runs only while a first sale earns more than it costs to win.

Bids on searches that show buying intent, and runs only while a first sale earns more than it costs to win.

```text title="Set it up · Ask"
Be our Marketing Lead for search ads: start with a $100 exact-match probe on Bing, block wasted terms every morning, and keep every bid under what a first sale is worth. No spend step without a holdout read and my OK. Show me what’s missing; don’t spend.
```

```text title="Run one · Ask"
Hand yesterday’s search terms report to the Marketing Lead agent and show me what it chose and why. Don’t change anything.
```

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

const { events: [last] } = await $.Event.list({ where: 'name in ("Integration.synced", "Keyword.measured", "Invoice.collected", "Organization.announced", "Offer.repriced", "Experiment.matured")', limit: 1 })   // { play } compiled to this Play’s Trigger Events
const { subject } = await $.Trigger.fire({ like: last.$id }, { mode: 'try' })   // the same Event again, in try mode · Not live yet, gaps #7
const { decisions: [decision] } = await $.Decision.list({ where: `on = "${subject}"` })   // what it chose, and why · Not live yet, gaps #7
const { actions } = await $.Action.list({ where: `on = "${subject}"` })   // what it did, with its checks · Not live yet, gaps #7
return { decision, actions }
```

```json title="Run one · Response · sample"
{
  "decision": {
    "$id": "https://api.sb/leaseline.app/decisions/decision_581",
    "$type": "Decision",
    "choice": "block-wasted-terms",
    "reason": "31 of 214 clicked terms match the negative rules in your file (jobs, free, template, login), and they cost $86 yesterday",
    "stakes": "low",
    "options": [
      "block-wasted-terms",
      "queue-new-terms",
      "reset-bids",
      "take-the-next-step",
      "defend-your-name",
      "not-now"
    ],
    "by": "worker_6",
    "on": "batch_44",
    "expected": "wasted spend under 10 % of the next 7 days",
    "reopensWhen": "wasted share over 10 % in the next Integration.synced terms report"
  },
  "actions": [
    {
      "$id": "https://api.sb/leaseline.app/actions/action_1",
      "$type": "Action",
      "status": "done",
      "mode": "try",
      "by": "worker_6",
      "verb": "Integration.sync",
      "checks": []
    },
    {
      "$id": "https://api.sb/leaseline.app/actions/action_2",
      "$type": "Action",
      "status": "done",
      "mode": "try",
      "by": "worker_6",
      "verb": "Keyword.create",
      "checks": [
        {
          "check": "scale-gate",
          "status": "pass"
        }
      ]
    },
    {
      "$id": "https://api.sb/leaseline.app/actions/action_3",
      "$type": "Action",
      "status": "pending",
      "reason": "awaitingApproval",
      "mode": "try",
      "by": "worker_6",
      "verb": "Approval.create",
      "checks": []
    }
  ]
}
```

`action_2` adds the 31 negatives through the write proxy at once, because your rules cover them. `action_3` queues 6 terms no rule covers for your OK in today’s batch; a batch waiting 48 hours halves the fenced arm’s budget.

`+13.6 per $100 · ■■□□ Early sign · n 56 days · path → arr`

| Try another: the terms report | Choice | Reason |
| --- | --- | --- |
| Oct 18 terms report: 31 of 214 clicked terms match your negative rules | `block-wasted-terms` | your rules cover them: blocked at once |
| “renewal notice software for landlords”: 9 clicks, 2 trials, no Keyword yet | `queue-new-terms` | a new term no rule covers: for your OK |
| The Bing probe’s 28-day switchback: +15.1 ICP Visits per $100, breakeven at 8 | `take-the-next-step` | above breakeven: open Google Search at $1,000, with your OK |
| A rival started bidding on “leaseline” | `defend-your-name` | a rival on your name: one brand ad group, for your OK |
| Google Search, day 14 of its holdout: +9.2 [−6.0, +24.4] | `not-now` | the holdout has not read: spend holds where it is |

| Worth | Your time | Spend | First signal | Use it when | Instead |
| --- | --- | --- | --- | --- | --- |
| ≈ +$1,000 ARR a month at $1,100 spend | 1 h 30 min week 1, then 20 min a week | $100 probe, then $1,000 a month | Day 9: first sale from a paid Visit | 500+ exact-intent searches a month, a known margin | Under 500 a month: `editorial-seo` · no known margin: set the cost (<Op verb="Offer.update" />) |

**Where it sits:** **Stranger** → Lead → Trialing → Activated → At risk → Churned → Won back · Search ICP Visits per $100 → People reached → New Customers ↑ → ARR added each week → ARR

Worth’s formula: about +13.6 incremental ICP Visits per $100 (your switchback, ■■□□ Early sign) × $1,100 a month = 150 Visits × 0.02 Visits to paid × $347 Pro ARR ≈ +$1,040. Under First signal: A day switchback at $1,100 a month tells a +10 lift per $100 in about 8 weeks; each scale step waits for a 28-day holdout read.

`sample · api.sb/leaseline.app · 41 Customers at 2026-10-19 · on Integration.synced · negatives applied 6 min after the terms report · 9 keys Not live yet`

## Start it this week
```bash
npx startup-builder add play paid-search   # 1 · writes plays/paid-search.mdx (Not live yet, gaps #5)

# 2 · dry run; below, its 422 on 2026-08-24
curl -X POST "https://api.sb/leaseline.app/campaigns?mode=try" \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"play":"paid-search"}'

# 3 · keep it: every change waits for your OK
curl -X POST https://api.sb/leaseline.app/campaigns \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"play":"paid-search"}'

# the probe’s $100 monthly Limit
curl -X POST https://api.sb/leaseline.app/limits \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"paid-search-month","amount":100,"period":"month"}'

# off: every Campaign pauses through the proxy, and write scope is revoked
curl -X PATCH https://api.sb/leaseline.app/campaigns/paid-search \
  -H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endsAt":"now"}'
```


```json title="The dry run’s 422 · 2026-08-24 · sample"
{
  "type": "https://startupbuilder.dev/errors/refused",
  "title": "Refused",
  "status": 422,
  "detail": "paid-search can’t start yet: 2 checks fail and 1 warns.",
  "checks": [
    {
      "check": "use-when",
      "status": "pass",
      "observed": "1,900 exact-intent searches a month across 14 terms",
      "doc": "/plays/paid-search#check-use-when",
      "fix": "Nothing to do: it counts searches for your exact-intent terms.",
      "then": "Keyword.measure"
    },
    {
      "check": "baseline",
      "status": "warn",
      "observed": "0 days of paid spend: no switchback yet",
      "doc": "/plays/paid-search#check-baseline",
      "fix": "n 0: the probe’s switchback becomes the baseline in 28 days.",
      "then": "Metric.query"
    },
    {
      "check": "billing-connected",
      "status": "fail",
      "doc": "/plays/paid-search#check-billing-connected",
      "fix": "Connect Stripe, so every gate reads first sales from your own records.",
      "then": "Integration.connect"
    },
    {
      "check": "fact-check",
      "status": "fail",
      "observed": "no claims source for ad text",
      "doc": "/plays/paid-search#check-fact-check",
      "fix": "Approve the claims ads may state; every ad line must cite one.",
      "then": "Content.draft"
    },
    {
      "check": "role-unstaffed",
      "status": "pass",
      "observed": "every approval comes to you",
      "doc": "/plays/paid-search#check-role-unstaffed",
      "fix": "Nothing to do: a founder-only Startup approves every change.",
      "then": "Role.staff"
    }
  ]
}
```

| Check | Fix (one call) | Your time | Wait |
| --- | --- | --- | --- |
| <a id="check-use-when"></a>`use-when` · <a id="check-baseline"></a>`baseline` · pass: 1,900 searches a month; warn: no switchback yet (gaps #8) | <Op verb="Keyword.measure" /> the 14 terms; <Op verb="Metric.query" /> fills Before as the probe runs | 2 min | 28 days for the first read |
| <a id="check-billing-connected"></a>`billing-connected` · fail | <Op verb="Integration.connect" /> with `system` recurring-billing (Stripe) | 5 min | the first sync, about 10 min |
| <a id="check-fact-check"></a>`fact-check` · fail (Not live yet) | <Op verb="Content.draft" /> the claims source from your Pricing Page and approved Pages | 20 min | none |
| <a id="check-role-unstaffed"></a>`role-unstaffed` · pass: 0 staffing fails on a founder-only Startup | nothing; every change comes to you | 0 min | none |

`Your number · Before 0 · Now 13.6 per $100 · Target 12 by Dec 31 · ■■□□ Early sign · n 56 days · Other Startups +9.0, ■■■□ Likely`

The probe spends $100 a month on Bing, and Google Search opens at $1,000 a month only after a winning read; the Play itself costs about $0.40 a day to read reports and write through the proxy (Worker cost). That comes to $112 a month on the probe and about $1,112 after the step.

## When it starts
| When | Trigger id | Starts on | Window | How it gets in |
| --- | --- | --- | --- | --- |
| The Venue reports the terms people clicked since the last read | `search-term-batch` | `event` on `Integration.synced` where report = 'search-terms' | read each morning; a batch for you within 48 h | <Op verb="Integration.connect" /> with `venue` bing or google-search |
| A Customer’s first sale follows a paid Visit | `paid-first-transaction` | `event` on `Invoice.collected` where first = true and visit.paid = true | 30d | Stripe, through <Op verb="Integration.connect" />; never the Venue’s own count |
| A switchback or geo holdout is ready to read | `holdout-closed` | `event` on `Experiment.matured` where method in ('geo', 'switchback') | 7d to decide the next step | <Op verb="Experiment.start" /> set the dates |
| + 4 more: a new intent phrase, a rival on your name, a rival’s moment, and a change to your margin (`?lanes=all`) | | | | |

```json title="One signed delivery · StartupBuilder-Signature"
{
  "$id": "https://api.sb/leaseline.app/events/event_1402",
  "$type": "Event",
  "name": "Integration.synced",
  "at": "2026-10-19T06:04:30Z",
  "seq": 1402,
  "data": {
    "integration": "google-search",
    "report": "search-terms",
    "batch": {
      "$id": "https://api.sb/leaseline.app/batches/batch_44",
      "day": "2026-10-18",
      "clicked": 214,
      "spend": 52.4
    },
    "negativeMatches": 31,
    "wasted": 86
  }
}
```

```bash title="Reproduce it in test"
npx startup-builder trigger Integration.synced --like event_1402   # replays the report in test; the proxy writes nothing live
npx startup-builder clock advance --by 48h   # halves the fenced arm’s budget if the batch still waits
npx startup-builder listen Invoice.collected --forward-to http://localhost:3000/hook
```

## Who runs it
**Marketing Lead · your agent** (`role:marketing-lead`, `worker_6`) holds the Key Result and writes to the ads Venues only through the fenced proxy, which holds the token outside the model and creates every ad paused. **You** (Dana Okafor, person, `worker_1`) approve new terms, every spend step and every brand ad. Your number links to Did it work.

| Option | Rung today | Evidence (passed of n) | Moves up at | Drops back when | You can |
| --- | --- | --- | --- | --- | --- |
| Block the wasted terms · Reset bids to breakeven | It does it, you can stop it | 412 negatives · 3 resets | met on day 21 (95 % of 30 change sets passed unedited) | a `drift` or `fence-breach` → You approve each one | <Op verb="Action.cancel" /> before the proxy writes |
| Queue new terms for your OK · Bid on your own name | You approve each one | 44 of 52 approved · 1 of 1 | capped at It does it, you can stop it, after 30 batches | — | <Op verb="Approval.approve" /> · <Op verb="Approval.refuse" /> |
| Take the next spend step | You approve each one | 1 of 1 | never: a spend step is yours | — | <Op verb="Approval.approve" />, then <Op verb="Limit.raise" /> |
| Not now | It does it | 4 of 4 | — | — | <Op verb="Action.redirect" /> |
| Change a negative rule | You approve each one | 1 merged of 1 | never | — | <Op verb="Change.merge" /> · <Op verb="Change.revert" /> |
| Never: a rival’s name in ad text, a review quote you didn’t verify, the Venue’s auto-made assets | Refused, every time | 1 drafted, 1 refused | never | — | none: the Check holds it |

**What it reads · What it may call.** It reads the terms report, spend, your Offers’ margin and first sales from Stripe, never clicks or the Venue’s own credit. A search term is data: it never enters ad text and never instructs the agent. Its tools are <Op verb="Keyword.create" />, <Op verb="Approval.create" />, <Op verb="Ad.update" />, <Op verb="Limit.raise" />, <Op verb="Experiment.start" />, <Op verb="Ad.create" />, and any other call becomes an Approval under the Role’s Mandate.

```md title="The brief (folded): compiled Agent-register Markdown · play:paid-search@1 · each line names its record key"
You hold the Marketing Lead Key Result: 12 incremental ICP Visits per $100 by 2026-12-31. ← worker, metric
On each report, sale, margin change or holdout, choose one option and say why.   ← triggers, chooses
Every bid stays at or under breakeven for a first sale.                          ← Payback gate, your file line 5
A Price in ad text equals the Offer’s live Price; no urgency the Offer lacks.    ← copyRules 2, 4
A rival’s name is a Keyword, never ad text.                                      ← copyRules 3
No spend step without a winning holdout read and Dana’s OK.                      ← Scale gate, chooses.take-the-next-step
```

**The slow loop, as a Change.** `decision_396` (2026-09-21, weekly) read $142 on terms with “template” and “free” over 3 weeks, with 0 first sales. `change_28` added both words to the negative rules (your file, line 6), and Dana merged it with <Op verb="Change.merge" />. **Replayed on past reports**, <Op verb="Change.review" /> cut wasted spend from 18 % to 7 %; read back over the next 14 days it is 8 %, n 2,960 clicks, ■■□□ Early sign (Example, day 42).

**Bring your own agent** (folded) starts at It does it, you can stop it when it matches or beats the seated agent on the eval suite, and at You approve each one when it does not; it still writes only through the proxy.

```bash
npx startup-builder eval --scenario paid-search-56 --agent "claude -p" --seat marketing-lead   # seated: 8 % wasted, 0 Checks failed sent · yours: 11 % wasted → You approve each one
```

## What it can do next
| Option | Picked when | Decided by | Runs | Hands to | If it’s just you |
| --- | --- | --- | --- | --- | --- |
| Block the wasted terms `block-wasted-terms` | clicked terms match a negative rule | Your rule (line 6) | <Op verb="Keyword.create" /> as negatives, through the proxy | nobody | the same |
| Queue new terms for your OK `queue-new-terms` | a term or negative no rule covers | Agent’s call, with its reason | <Op verb="Keyword.create" />, paused → <Op verb="Approval.create" /> in today’s batch | you approve the batch · Take this one | you |
| Reset bids to breakeven `reset-bids` | an Offer’s Price or cost changed | Your rule (line 5) | <Op verb="Ad.update" /> to the new breakeven bids | nobody | the same |
| Take the next spend step `take-the-next-step` | a holdout read beats breakeven | Agent’s call, with its reason | <Op verb="Approval.create" /> → on yes, <Op verb="Limit.raise" /> → <Op verb="Experiment.start" /> for the next 28-day read | you approve it · Take this one | you |
| Bid on your own name `defend-your-name` | a rival bids on your name | Agent’s call, with its reason | <Op verb="Ad.create" /> for one brand ad group, paused → <Op verb="Approval.create" /> | you approve it · Take this one | you |
| Not now `not-now` | the holdout has not read, or the payback gate is near its line | Agent’s call, with its reason | nothing; names what holds it: the open Experiment or the payback gate | nobody | the same |

| When they write back | Fixture reply | What happens | Who answers | By when | Call |
| --- | --- | --- | --- | --- | --- |
| Interested | a demo form from an ad click: “340 units, can we see it?” | the form starts `instant-response`, which answers and books; this Play only counts the paid Visit | SDR · your agent, then AE · Priya Shah | 5 min | <Op verb="Lead.create" /> |
| Send me pricing | a click on the pricing ad, then the Pricing Page | the ad’s Price equals the live Price; nothing follows them | nobody | — | <Op verb="Decision.list" /> |
| Not now | a click, then no form and no trial | nothing follows; retargeting runs only in its own Play | nobody | — | <Op verb="Decision.list" /> |
| Wrong person | clicks on “leaseline login” from Customers | a rule negative blocks the term the next morning; Customers see no ad for it | Marketing Lead · your agent | the next report | <Op verb="Keyword.create" /> |
| Out of office | clicks on days the switchback holds ads off | those days are the comparison; no ad shows | nobody | — | <Op verb="Experiment.get" /> |
| Question it can’t answer | a chat from an ad click: “Do you integrate with our accounting system?” | the chat starts `instant-response`, which hands it to a person | AE · Priya Shah (person) | 60 min | <Op verb="Conversation.escalate" /> |
| Stop | an ad-preferences block on the Venue | the Venue stops showing the ad to them; no list of them is kept | nobody | at once | <Op verb="Decision.list" /> |

## When it hands to you
Every new term batch, spend step and brand ad comes to **you**: the Marketing Lead Role has no person, and a spend step is never the agent’s call. One Brief, as captured, then the Approval (`brief`, `draft` and `checks` are Not live yet, gaps #7).

```json title="What lands in each queue · sample"
[
  {
    "$id": "https://api.sb/leaseline.app/tasks/task_427",
    "$type": "Task",
    "assignee": "worker_1",
    "due": "2026-09-30T17:00:00Z",
    "on": "experiment_6",
    "brief": {
      "what": "the Bing probe’s 28-day switchback",
      "read": "+15.1 incremental ICP Visits per $100 [+4.2, +26.0], breakeven 8",
      "chose": "take-the-next-step",
      "why": "above breakeven on a winning read",
      "step": "open Google Search at $1,000 a month",
      "never": [
        "a raise without your OK",
        "the fenced arm before 30 first sales"
      ]
    }
  },
  {
    "$id": "https://api.sb/leaseline.app/approvals/approval_96",
    "$type": "Approval",
    "verdict": null,
    "deadline": "2026-10-21T06:00:00Z",
    "draft": "6 new terms, paused: “renewal notice software for landlords”, “owner statement software”…",
    "route": "queue-new-terms",
    "checks": [
      {
        "check": "fact-check",
        "status": "pass"
      },
      {
        "check": "scale-gate",
        "status": "pass"
      }
    ]
  }
]
```

You answer with <Op verb="Approval.approve" /> or <Op verb="Approval.refuse" />, or <Op verb="Task.claim" /> (Take this one) and <Op verb="Task.return" /> with a reason, each a one-call window. A term batch unanswered for 48 hours halves the fenced arm’s budget, and at 96 hours the arm pauses (`negative-backlog`); an unanswered spend step simply never happens.

**On time:** you, 7 of 8 batches answered within 48 h, median 9 h, n 8, and 1 of 1 spend steps within its day, ■■□□ Early sign beside Other Startups’ 88 % on time. **Your queue:** counted by kind: in week 1, the account plan and 3 batches; now about 2 batches a week, and a spend step at each holdout read.

**The fixed line:** <Op verb="Deal.assign" /> sets `owner` to the receiving person, the agent is never a Deal’s owner, and Meetings land on the owner’s calendar under the owner’s name.

## When it won’t send
| Check (refuses one send) | Refuses | Fix | Then |
| --- | --- | --- | --- |
| `fact-check` (Not live yet) | ad text with a claim the claims source lacks, or a Price that differs from the Offer’s live Price | Cut the claim, or name the record that backs it. | <Op verb="Content.draft" /> |
| <a id="check-scale-gate"></a>`scale-gate` (the record’s Scale gate) | a Limit raise, a new arm or a new Venue with no winning holdout read | Run the 28-day holdout first; only the probe’s step reads on the probe. | <Op verb="Experiment.start" /> |

| Stop (pauses the Play) | Pauses when | Then |
| --- | --- | --- |
| <a id="stop-payback"></a>`payback` | a 14-day cost per first sale above first-sale margin, at 5 or more first sales | the Play pauses |
| <a id="stop-zero-transaction"></a>`zero-transaction` | 14-day spend over 3 times the margin with no first sale | the Play pauses |
| <a id="stop-ad-group"></a>`ad-group` | an ad group spends 3 times breakeven with no first sale | that ad group pauses |
| <a id="stop-spend-limit"></a>`spend-limit` | month-to-date spend reaches the monthly Limit | the Play pauses until the month turns; <Op verb="Limit.raise" /> needs a winning read |
| <a id="stop-conversion-feed"></a>`conversion-feed` | a failed conversion send, or none for 3 times the expected gap, 72 h at least | bid changes freeze; at 7 times the gap the Play pauses |
| <a id="stop-feed-mismatch"></a>`feed-mismatch` | the Venue’s first sales differ from Stripe’s by over 30 % for 7 days | bid changes freeze |
| <a id="stop-drift"></a>`drift` | a live ad or setting differs from the approved state | it pauses or resets at once; a second drift in 7 days pauses the Campaign |
| <a id="stop-landing"></a>`landing` | a landing Page fails its status or Price check | its ad groups pause |
| <a id="stop-fence-breach"></a>`fence-breach` | 3 refused writes through the proxy in one day | the Play drops to read-only |
| <a id="stop-kill-switch"></a>`kill-switch` | you press it | every Campaign pauses through the proxy, and write scope is revoked |
| <a id="stop-negative-backlog"></a>`negative-backlog` | a term batch waits 48 h for you | the fenced arm’s budget halves; at 96 h the arm pauses |
| <a id="stop-fenced-arm"></a>`fenced-arm` | the fenced arm is marked the loser | it pauses at once and stays off until a new Decision |

The captured refusal, then the Stop that fired as a `Campaign.paused` reason (Not live yet, gaps #2); the rewrite that went live is the Sep 8 ad in Example.

```json title="The captured refusal · sample"
{
  "type": "https://startupbuilder.dev/errors/refused",
  "title": "Refused",
  "status": 422,
  "detail": "The new ad for “lease renewal software” was not created: one headline states a result the claims source lacks.",
  "checks": [
    {
      "check": "fact-check",
      "status": "fail",
      "doc": "/plays/paid-search#check-fact-check",
      "fix": "Cut the claim, or name the record that backs it.",
      "then": "Content.draft"
    },
    {
      "check": "scale-gate",
      "status": "pass"
    }
  ]
}
```


```json title="The Stops that fired, as Campaign.paused reasons · sample"
[
  {
    "reason": {
      "limit": "conversion-feed",
      "observed": "0 conversions sent in 81 h",
      "bound": "72 h (3 × the 24 h gap)",
      "window": "Oct 2 to Oct 5"
    },
    "doc": "/plays/paid-search#stop-conversion-feed",
    "task": "task_433"
  }
]
```

## Did it work
Days were assigned at random, ads on or off (a switchback, rule o: 28 days each half); both halves are read from that assignment. Each figure’s call is <Op verb="Metric.query" /> by node and by arm; the parent field and every node above the leaf are Not live yet (gaps #4).

`+13.6 per $100 · ■■□□ Early sign · n 56 days · path → arr`

| Node | Ads-on days | Ads-off days | Added | Confidence | Basis |
| --- | --- | --- | --- | --- | --- |
| `search-icp-visits-per-100` | 1,140 ICP Visits on $1,180 spend | 980 ICP Visits, $0 spend | +13.6 incremental ICP Visits per $100 [+4.1, +23.1] | ■■□□ Early sign | days switched at random, 28 a half · n 56 days · Other Startups +9.0, ■■■□ Likely, n 47 Startups |
| `icp-reached` | 1,140 a half (285 a week) | 980 (245 a week) | +160 [+48, +272] | ■■□□ Early sign | the same assignment |
| `new-arr` ↑ | $1,388, 4 Customers from paid Visits · Booked: credited, not all caused by this Play | $347, 1 Customer | +$1,041 [−$700, +$2,800] · Not carrying up yet · Worth about +$1,000 a month | ■□□□ Too early to tell (4 vs 1 Customers) | the same · 6 trials still deciding |
| `net-new-arr` · `arr` | +$1,388 | +$347 | +$1,041, carried: it moves no other bridge term | ■□□□ Too early to tell | carried from `new-arr` |

The leaf counts ICP Visits on ads-on days minus ads-off days, for each $100 spent, over the switchback’s 56 days; the Google Search step on day 35 keeps the same day assignment, and its own 28-day holdout reads on Nov 22.

**How it moves it:** ads showed to people searching your exact-intent terms at bids at or under breakeven, and 412 wasted terms were blocked the next morning (`search-icp-visits-per-100` by term).

**Guardrails:** treated against held back: cost per first sale, $295 against a $347 margin, and Visits to paid, 0.35 % against 0.10 %.

```json title="The Campaign card · sample"
{
  "$id": "https://api.sb/leaseline.app/campaigns/paid-search",
  "$type": "Campaign",
  "play": "play:paid-search@1",
  "path": [
    "search-icp-visits-per-100",
    "icp-reached",
    "new-arr",
    "net-new-arr",
    "arr"
  ],
  "experiment": {
    "metric": "search-icp-visits-per-100",
    "unit": "per-100-usd",
    "heldBack": 28,
    "decision": null
  },
  "readout": {
    "value": 13.6,
    "n": 56,
    "maturing": 0,
    "interval": [
      4.1,
      23.1
    ],
    "readability": "B",
    "basis": "alternating-weeks",
    "pooled": false
  },
  "ledger": {
    "spend": 1180,
    "workerCost": 22,
    "hours": 5.4,
    "pipelineArr": 2400,
    "wonArr": 1388,
    "projectedArr": 1040,
    "paybackDays": 310
  }
}
```

Outside benchmark: the catalog holds none for this Play yet, so Other Startups’ results stand in: +9.0 incremental ICP Visits per $100, ■■■□ Likely, n 47 Startups.

**Before it · After it.** Before it: `editorial-seo` and `aeo` find the exact-intent terms worth a bid. After it: every form, chat or email from a paid Visit starts `instant-response`, and `retargeting` hears the Visitors who left without one.

## Example
Leaseline ran it from 2026-08-24 to 2026-10-19 (56 days): the Bing probe from day 0, Google Search from day 35 at $1,000 a month. $1,180 spent on 28 ads-on days; 412 negatives blocked and 44 new terms approved. Read every captured thread at `?threads=all`; three follow the tables.

| What happened, by option | Decisions | Done | Won (credited, not all caused by this Play) |
| --- | --- | --- | --- |
| Block the wasted terms · Reset bids to breakeven | 38 · 3 | 412 negatives · 3 resets | — |
| Queue new terms for your OK | 8 batches | 44 of 52 approved | 3 on Pro from approved terms (+$1,041 ARR) |
| Take the next spend step · Bid on your own name · Not now | 1 · 1 · 4 | Google Search opened · 1 brand ad group · spend held 4 times | 1 on Pro (+$347 ARR) · 0 · — |
| At 500 searches a month (the Use it when volume) | about $300 a month of spend | switchback: Too early to tell at 90 days | Other Startups’ results: +9.0 per $100, ■■■□ Likely, n 47 Startups |

| Day | Event | What happened | Your time |
| --- | --- | --- | --- |
| 0 | `event_118` Campaign.created | the three steps; the dry run fails `billing-connected` and `fact-check` | 35 min |
| 0 | `event_121` Integration.connected | Stripe connected; the claims source approved with 9 claims | 25 min |
| 9 | `event_230` Invoice.collected | the first sale from a paid Visit: a probe click on “lease renewal software” | — |
| 21 | `event_420` Role.updated | promoted: rule negatives and bid resets apply without waiting | — |
| 28 · 42 | `event_560` Change.merged · `event_744` Readout.created | “template” and “free” added to the negative rules: replayed at 7 % wasted, read back at 8 % | 5 min |
| 35 | `event_640` Limit.raised | the probe’s switchback read +15.1 per $100; you approved Google Search at $1,000 a month | 10 min |
| 39 | `event_702` Campaign.paused | `conversion-feed`: no conversion sent for 81 h; the send was fixed and bids unfroze | 20 min |
| 1–56 | `event_140` Approval.created, first of 9 | you approved 8 term batches and 1 spend step; weekly reviews, from the <Op verb="Task.next" /> log | 3 h 40 min · **total 5.4 h measured, 5 h asked** |

> **Search: “lease renewal software”, Sep 2, 10:14** · the ad: Lease renewals, sent 60 days out · Per-owner notices and reports · Pro from $29 a month.¹
>
> **The click** · landed on the renewals Page; a trial started at 10:22 and paid on Pro on Sep 16. Stripe recorded the first sale, and the conversion send reached the Venue at 10:24 the next day.²

```diff title="The “lease renewal software” ad, 2026-09-08 · fact-check refused, then the version that went live"
- Cut late renewals by 40%
+ Renewal notices sent 60 days out
```

> **The auction report, 2026-10-06** · a rival began bidding on “leaseline” · the agent drafted one brand ad group, paused, and the Approval went to Dana with a deadline of Oct 7 at 06:00 (`approval_91`).³ Dana approved it at 21:40 on Oct 6, inside the deadline, and the ad group went live through the proxy at 21:41.

¹ The ad’s Price equals Pro’s live Price; no rival name in ad text. ² Every gate counts the sale from Stripe, never the Venue’s own credit. ³ A brand ad group is yours to approve, on every rung.

## Your settings
```mdx title="plays/paid-search.mdx"
---
limit: 1000       # dollars a month; it was 100 for the probe
holdout: switchback-days
---
Bids stay at or under breakeven for a first sale.
Block terms with jobs, free, template or login the next morning.
No spend step without a holdout read and my OK.
```

```text
$ npx startup-builder show plays/paid-search
limit $1,000 a month · day switchback · breakeven bids · 4 negative rules · spend steps to you     your file  plays/paid-search.mdx:2,3,5,6,7 (Ask 2026-08-24; line 2 by your OK on day 35, line 6 by change_28, 2026-09-21)
family ads · worker role:marketing-lead, owns never · chooses 6 (2 rule, 4 read) · approval every-send · checks fact-check, scale-gate · stops 12 · placements google-search, bing, through the write proxy    catalog  play:paid-search@1 (Not live yet, gaps #5)
```

### Every key from the catalog
| Key | Id | What it is |
| --- | --- | --- |
| Tactic | <a id="tactic-search-account-build"></a>`search-account-build` | Search account build researches intent phrases and queues a complete, read-only account plan of ad groups, ads, negatives and breakeven bids. Starts at You approve each one. |
| Tactic | <a id="tactic-ad-write-proxy"></a>`ad-write-proxy` | Ad write proxy is the only write path to an ads Venue, holding the token outside the model and forcing every create to paused. Starts at You approve each one. |
| Tactic | <a id="tactic-search-term-triage"></a>`search-term-triage` | Search term triage reads every clicked search term daily, applies rule negatives at once, and batches model-proposed negatives and Keywords for a human. Starts at You approve each one. |
| Tactic | <a id="tactic-fenced-match-arm"></a>`fenced-match-arm` | Fenced match arm runs the Venue’s automated matching in its own Campaign, capped at a fifth of the budget and walled off from core Keywords. Starts at You approve each one. |
| Tactic | <a id="tactic-incrementality-holdout"></a>`incrementality-holdout` | Incrementality holdout measures the first Transactions a paid arm adds, by a geo holdout or a day switchback, before any scale step. Starts at You approve each one. |
| Tactic | <a id="tactic-payback-gate"></a>`payback-gate` | Payback gate pauses a paid Play the moment its cost per first Transaction outruns first-transaction margin, with no approval and no wait. Starts at Sends nothing. |
| Tactic | <a id="tactic-ad-drift-monitor"></a>`ad-drift-monitor` | Ad drift monitor diffs every live Ad’s rendered creative, text and enhancement settings against the approved source daily, and pauses or resets what drifted. Starts at Sends nothing. |
| Trigger | <a id="trigger-search-term-batch"></a>`search-term-batch` | Search term batch: `event` on `Integration.synced`, window 7d. |
| Trigger | <a id="trigger-intent-phrase-found"></a>`intent-phrase-found` | Intent phrase found: `event` on `Keyword.measured`, window 30d. |
| Trigger | <a id="trigger-paid-first-transaction"></a>`paid-first-transaction` | Paid first transaction: `event` on `Invoice.collected`, window 30d. |
| Trigger | <a id="trigger-rival-on-brand"></a>`rival-on-brand` | Rival on brand: `event` on `Integration.synced`, window 7d. |
| Trigger | <a id="trigger-rival-moment"></a>`rival-moment` | Rival moment: `event` on `Organization.announced`, window 14d. |
| Trigger | <a id="trigger-margin-change"></a>`margin-change` | Margin change: `event` on `Offer.repriced`, window 1d. |
| Trigger | <a id="trigger-holdout-closed"></a>`holdout-closed` | Holdout closed: `event` on `Experiment.matured`, window 7d. |