Product-led growth
Gives free Customers enough to reach the result twice, then shows the upgrade inside the task at the Limit, with the real Price.
- Step
- Make the sale
- Channel
- Signup flows
- Kind
- Conversion
- Summary
- startupbuilder.dev
Be our CSM: give free accounts enough lease reads to send their first notices twice, then show Pro at the limit, inside the task, with the real price. Over 150 units, bring in our AE. Keep half on today’s free plan. Show me what’s missing; don’t send.
# Not live yet (gaps #2)
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":"product-led"}'import { $ } from 'startup-builder'
return await $.Campaign.create({ play: 'product-led' }, { mode: 'try' }) // dry run on the day-0 Startup, 2026-08-24 · Not live yet, gaps #2
npx startup-builder do Campaign.create --json '{"play":"product-led"}' --try # Not live yet (gaps #2){
"name": "do",
"arguments": {
"code": "return await $.Campaign.create({ play: 'product-led' }, { mode: 'try' }) // dry run on the day-0 Startup, 2026-08-24 · Not live yet, gaps #2",
"mode": "try"
}
}application/json{
"type": "https: //startupbuilder.dev/ errors/ refused",
"title": "Refused",
"status": 422,
"detail": "product-led can’t start yet: 1 check fails and 1 warns.",
"checks": [
{
"check": "use-when",
"status": "pass",
"observed": "99 free signups in 30 days",
"doc": "/plays/ product-led#check-use-when",
"fix": "Keep 20 or more free signups a month, or run signup-interviews.",
"then": "Metric.query"
},
…
Hand our latest free account at a limit to the CSM agent and show me what it chose and why. Don’t send.
curl "https://api.sb/leaseline.app/events?where=name%20in%20(%22Entitlement.checked%22%2C%20%22Event.captured%22%2C%20%22Customer.activated%22%2C%20%22Offer.subscribed%22%2C%20%22Subscription.ended%22%2C%20%22Version.deployed%22%2C%20%22Metric.watched%22)&limit=1" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"
# Not live yet (gaps #7)
curl -X POST "https://api.sb/leaseline.app/triggers/fire?mode=try" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"like":"event_1310"}'
# Not live yet (gaps #7)
curl "https://api.sb/leaseline.app/decisions?where=on%20%3D%20%22customer_44%22" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"
# Not live yet (gaps #7)
curl "https://api.sb/leaseline.app/actions?where=on%20%3D%20%22customer_44%22" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"import { $ } from 'startup-builder'
const { events: [last] } = await $.Event.list({ where: 'name in ("Entitlement.checked", "Event.captured", "Customer.activated", "Offer.subscribed", "Subscription.ended", "Version.deployed", "Metric.watched")', 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 }
npx startup-builder do Event.list --json '{"where":"name in (\"Entitlement.checked\", \"Event.captured\", \"Customer.activated\", \"Offer.subscribed\", \"Subscription.ended\", \"Version.deployed\", \"Metric.watched\")","limit":1}'
npx startup-builder do Trigger.fire --json '{"like":"event_1310"}' --try # Not live yet (gaps #7)
npx startup-builder do Decision.list --json '{"where":"on = \"customer_44\""}' # Not live yet (gaps #7)
npx startup-builder do Action.list --json '{"where":"on = \"customer_44\""}' # Not live yet (gaps #7){
"name": "do",
"arguments": {
"code": "const { events: [last] } = await $.Event.list({ where: 'name in (\"Entitlement.checked\", \"Event.captured\", \"Customer.activated\", \"Offer.subscribed\", \"Subscription.ended\", \"Version.deployed\", \"Metric.watched\")', limit: 1 }) // { play } compiled to this Play’s Trigger Events\nconst { subject } = await $.Trigger.fire({ like: last.$id }, { mode: 'try' }) // the same Event again, in try mode · Not live yet, gaps #7\nconst { decisions: [decision] } = await $.Decision.list({ where: `on = \"${subject}\"` }) // what it chose, and why · Not live yet, gaps #7\nconst { actions } = await $.Action.list({ where: `on = \"${subject}\"` }) // what it did, with its checks · Not live yet, gaps #7\nreturn { decision, actions }",
"mode": "try"
}
}application/json{
"decision": {
"$id": "https: //api.sb/ leaseline.app/ decisions/ decision_361",
"$type": "Decision",
"choice": "prompt-at-bound",
"reason": "the 10th lease read this month was refused, inside the renewal notice they were drafting, and Pro grants 200",
"stakes": "low",
"options": [
"prompt-at-bound",
"offer-paid-trial",
"upsell-month-one",
"win-back",
"to-the-ae",
"not-now"
…
sample · 4 Not live yetPreviewOn PATCH /{namespace}/campaigns/{id}Off PATCH /{namespace}/campaigns/{id}Take this one POST /{namespace}/tasks/{task}/claim
action_2 puts Oakfield in the agent half; the prompt shows inside the notice they were drafting. action_3 is the bound follow-up’s day-1 email, which ends when they upgrade or the grant resets.
+18.3 activated per 100 · ■■□□ Early sign · n 164 · path → arr
| Try another: the free account | Choice | Reason |
|---|---|---|
| Oakfield Lettings: 10 of 10 free lease reads used, the 10th refused mid-notice | prompt-at-bound | the bound met inside the task |
| Pinecrest Rentals: 14 notices sent in 6 days on free, no bound met | offer-paid-trial | the pattern that pays, before any limit |
| Juniper Homes: paid 12 days ago, a second member refused the owner report | upsell-month-one | Team grants the owner report |
| Cedar Lane: ended Sep 30; the reminder fix they asked for shipped Oct 14 | win-back | the reason they gave is fixed |
| Northgate Residential: 220 units on a free account, asked about a contract | to-the-ae | over 150 units, and terms are the AE’s |
- 35 min week 1, then 10 a week
- Your time
- Day 3: first upgrade paid
- First signal
- 20+ free signups a month, a metered Limit
- Use it when
Where it sits: Stranger → Lead → Trialing → Activated → At risk → Churned → Won back · Activation rate → New paying Customers → New Customers ↑ → ARR added each week → ARR
Worth’s formula: about +18.3 activated per 100 × 99 free signups a month × 0.24 activated to paid (your last 90 days) × $347 Pro ARR ≈ +$1,509. Under First signal: About 6 weeks to tell a +14 lift at 99 a month (4 to read, 2 to mature); at 20 a month, 90 days can tell only +31 or more, so your file starts at holdout:.
sample · api.sb/
Start it this week
npx startup-builder add play product-led # 1 · writes plays/product-led.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":"product-led"}'
# 3 · keep it, at You approve the first wave
curl -X POST https://api.sb/leaseline.app/campaigns \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"play":"product-led"}'
# on
curl -X PATCH https://api.sb/leaseline.app/campaigns/product-led \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endsAt":null}'
# off: no prompt or email sends; free grants and the cancel button stay
curl -X PATCH https://api.sb/leaseline.app/campaigns/product-led \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endsAt":"now"}' | Check | Fix | Then |
|---|---|---|
use-when pass · Not live yet, gaps #8 | Keep 20 or more free signups a month, or run signup-interviews. | POST /{namespace}/metrics/query |
limit-on-offer fail · Not live yet, gaps #8 | Add a 10-read monthly Limit to Leaseline Free, then publish it. | POST /{namespace}/offers/{offer}/publish |
billing-connected pass · Not live yet, gaps #8 | Connect Stripe so every grant, charge and Meter reading is live. | POST /{namespace}/integrations/connect |
baseline pass · Not live yet, gaps #8 | Read it once; it fills Before in Your number. | POST /{namespace}/metrics/query |
role-unstaffed warn · Not live yet, gaps #8 | Staff the CSM, or leave it: CSM routes come to you, and Priya’s 2 get drafts. | POST /{namespace}/roles/{role}/staff |
JSON
{
"type": "https://startupbuilder.dev/errors/refused",
"title": "Refused",
"status": 422,
"detail": "product-led can’t start yet: 1 check fails and 1 warns.",
"checks": [
{
"check": "use-when",
"status": "pass",
"observed": "99 free signups in 30 days",
"doc": "/plays/product-led#check-use-when",
"fix": "Keep 20 or more free signups a month, or run signup-interviews.",
"then": "Metric.query"
},
{
"check": "limit-on-offer",
"status": "fail",
"observed": "Leaseline Free: 5 units, lease reads unmetered",
"doc": "/plays/product-led#check-limit-on-offer",
"fix": "Add a 10-read monthly Limit to Leaseline Free, then publish it.",
"then": "Offer.publish"
},
{
"check": "billing-connected",
"status": "pass",
"observed": "Stripe, connected 2026-08-24",
"doc": "/plays/product-led#check-billing-connected",
"fix": "Connect Stripe so every grant, charge and Meter reading is live.",
"then": "Integration.connect"
},
{
"check": "baseline",
"status": "pass",
"observed": "31.7 activated per 100 free signups, n 212 in 90 days",
"doc": "/plays/product-led#check-baseline",
"fix": "Read it once; it fills Before in Your number.",
"then": "Metric.query"
},
{
"check": "role-unstaffed",
"status": "warn",
"roles": [
"success-manager"
],
"owned": 2,
"doc": "/plays/product-led#check-role-unstaffed",
"fix": "Staff the CSM, or leave it: CSM routes come to you, and Priya’s 2 get drafts.",
"then": "Role.staff"
}
]
}It says no, says why, and names the fix.
| Check | Fix (one call) | Your time | Wait |
|---|---|---|---|
use-when · baseline · pass: 99 free signups a month; 31.7 activated per 100, n 212 (gaps #8) | POST /{namespace}/metrics/query on activation-rate, which fills Before in Your number | 1 min | none |
limit-on-offer · fail (gaps #8): lease reads on Leaseline Free have no Limit | POST /{namespace}/offers/{offer}/publish with a 10-read monthly Limit, 2 a day | 10 min | none; new signups get it, existing free accounts after 30 days’ notice |
billing-connected · pass: Stripe | POST /{namespace}/integrations/connect with system recurring-billing (Stripe) | 0 min | none |
role-unstaffed · warn (gaps #8): 2 free accounts have a person owner, Priya | POST /{namespace}/roles/{role}/staff, or leave it: CSM routes come to you, and Priya’s 2 get drafts in her queue | 0 min | none |
Your number · Before 31.7 · Now 50.0 activated per 100 · Target 55 by Dec 31 · ■■□□ Early sign · n 164 matured
Each lease read on a free grant costs $0.03 from the free Offer pool, booked as marketing spend; each prompt email costs $0.04 from your Domain (Worker cost); POST /{namespace}/entitlements/check costs nothing. That comes to about $21 a month at 99 free signups, inside the $50 pool, and $4 at 20.
When it starts
| When | Trigger id | Starts on | Window | How it gets in |
|---|---|---|---|---|
| A free account meets a metered limit, or has 20 % or less left | usage-threshold | threshold on Entitlement.checked where allowed = false, or 20 % or less of the grant left | prompt inside the task; follow-up within 2d | your app calls POST /{namespace}/entitlements/check before each lease read |
| A free account does what paying accounts did in their first week | paid-predictor | threshold on Event.captured where the fitted usage Event reaches its fitted count within 7 days of signup | 7d from signup | your app calls POST /{namespace}/events/capture on each notice sent |
| A new paying account nears a Limit, or a member is refused | limit-pressure | threshold on Entitlement.checked where used / | 3d, inside the first 30 paid days | the same POST /{namespace}/entitlements/check |
+ 6 more for activation, first paid, cancels, fixes, cost shifts and the pool (?lanes=) |
{
"$id": "https://api.sb/leaseline.app/events/event_1310" ,
"$type": "Event",
"name": "Entitlement.checked",
"at": "2026-10-19T10:12:40Z" ,
"seq": 1310,
"data": {
"customer": "customer_44",
"offer": "leaseline-free",
"limit": "lease-reads",
"used": 10,
"bound": 10,
"allowed": false,
"resets": "2026-11-01"
}
}npx startup-builder trigger Entitlement.checked --like event_1310 # replays the delivery
npx startup-builder clock advance --by 1d # runs the bound follow-up’s day-1 email (action_3)
npx startup-builder listen Entitlement.checked --forward-to http://localhost:3000/hook Who runs it
CSM · your agent (role:, worker_5) holds the Key Result. It hands to AE · Priya Shah (person) (role:, worker_3) for free accounts over 150 units and any ask for terms, and to you (Dana Okafor, person, worker_1) for the rest, because no person holds the CSM seat. Your number links to Did it work.
| Option | Rung today | Evidence (passed of n) | Moves up at | Drops back when | You can |
|---|---|---|---|---|---|
| Prompt at the limit · Upsell in month one | It does it, you can stop it | 38 of 38 · 6 of 6 | met on day 9 (the first wave of 20) | any prompt fails a Check → You approve the first wave | POST /{namespace}/actions/{action}/cancel inside the hold |
| Offer the paid trial · Win back | You approve the first wave | 19 of 20 (1 refused) · 4 of 4 | at 20 passed in a row | — | POST /{namespace}/approvals/{approval}/approve · POST /{namespace}/approvals/{approval}/refuse |
| To the AE · Not now | It does it | 1 of 1 · 3 of 3 | — | a held account is asked anyway → You approve each one | POST /{namespace}/actions/{action}/redirect |
| Change the free grant | You approve each one | 1 merged of 1 | never | — | POST /{namespace}/changes/{change}/merge · POST /{namespace}/changes/{change}/revert |
| Never: a discount, a countdown that isn’t real, a cancel that waits | To a person, every time | 2 asked, 2 handed over | never | — | POST /{namespace}/approvals/{approval}/approve on the AE’s Quote |
What it reads · What it may call. It reads the account’s own Events, the Entitlement and the live Price; a predictor score or a matched company never appears in what it writes. The cancel is one press and never waits on the agent (exit-never-waits). Its tools are POST /{namespace}/entitlements/check, POST /{namespace}/offers/{offer}/trial, POST /{namespace}/offers/{offer}/subscribe, POST /{namespace}/conversations/{conversation}/escalate, POST /{namespace}/tasks/{task}/assign, and any other call becomes an Approval under the Role’s Mandate.
You hold the CSM Key Result: 55 activated per 100 free signups by 2026-12-31. ← worker, metric
On each Trigger, choose one option and say why; a held account gets Not now. ← triggers, chooses, keep-first
Free grant: 10 lease reads a month, twice what activation takes. ← your file, line 4
A prompt states the units left, the reset time and Pro’s price from the Pricing Page. ← copyRules 1
Never a countdown, a discount or a win-back offer below list. ← copyRules 4, 8
Never write to an account a person owns: draft it into their queue. ← worker.owns, owned-by-a-person The slow loop, as a Change. decision_142 (2026-09-07, weekly) read that activation took a median of 5 lease reads and that 31 of 40 free accounts hit the old 6-read cap before their second notice, so change_19 raised the grant to 10 (your file, line 4) and Dana merged it with POST /{namespace}/changes/{change}/merge. Replayed on past signups, POST /{namespace}/changes/{change}/review put the agent half at 48.1 per 100; read back at +30d it is 50.0, n 82, ■■□□ Early sign (Example, day 44).
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 the first wave when it does not; POST /{namespace}/roles/{role}/staff seats it.
npx startup-builder eval --scenario product-led-184 --agent "claude -p" --seat success-manager # seated: 50.0 per 100, 0 Checks failed sent, $0.21 a signup · yours: 47.6, 1 failed, $0.26 → You approve the first wave What it can do next
| Option | Picked when | Decided by | Runs | Hands to | If it’s just you |
|---|---|---|---|---|---|
Prompt at the limit prompt-at-bound | a metered limit met, or 20 % or less left | Your rule (line 4) | POST /{namespace}/entitlements/check → the prompt inside the task → bound-follow-up | the Customer’s press | the same |
Offer the paid trial offer-paid-trial | the pattern that pays, before any limit | Agent’s call, with its reason | trial-to-paid → POST /{namespace}/offers/{offer}/trial → convert-trial | the Customer’s press | the same |
Upsell in month one upsell-month-one | paid under 30 days, at 80 % of a Limit or a member refused | Your rule (line 5) | first-30-upsell → on press change-plan | the Customer’s press | the same |
Win back win-back | a canceled plan ended, or their reason got fixed | Agent’s call, with its reason | win-back-1-30-60 → on press POST /{namespace}/offers/{offer}/subscribe | nobody; restore is one press | the same |
To the AE to-the-ae | over 150 units, or an ask for terms | Agent’s call, with its reason | POST /{namespace}/conversations/{conversation}/escalate → POST /{namespace}/tasks/{task}/assign | POST /{namespace}/tasks/{task}/assign → AE · Priya Shah, due 1 business day · Take this one | you, within 1 business day |
Not now not-now | another Play holds them, or keep-first applies | Your rule (rule j) | nothing; names the holder | their CSM, so you · Take this one | you |
| When they write back | Fixture reply | What happens | Who answers | By when | Call |
|---|---|---|---|---|---|
| Interested | “OK, move us to Pro.” | the one-press link in the prompt; nothing is charged until they press | the Customer | — | POST /{namespace}/subscriptions/{subscription}/switch (theirs) |
| Send me pricing | “What does Pro cost for 30 units?” | Pro’s live Price and what it grants, from the Pricing Page | CSM · your agent | 5 min | POST /{namespace}/conversations/{conversation}/answer |
| Not now | “We’ll stay on free for now.” | the follow-up stops; the grant resets on the 1st as stated | nobody | at once | GET /{namespace}/decisions |
| Wrong person | “Our office manager pays for software.” | asks them to forward it; it never writes to an address from their text | CSM · your agent | 1 business day | POST /{namespace}/conversations/{conversation}/answer |
| Out of office | an auto-reply until Oct 27 | the follow-up waits for the return date | nobody | the return date | POST /{namespace}/messages/{message}/schedule |
| Question it can’t answer | “Can you import our leases from the old system?” | To a person, the question in the Brief | you | 1 business day | POST /{namespace}/conversations/{conversation}/escalate |
| Stop | “Stop emailing me about plans.” | emails stop; the in-task prompt keeps showing at the limit | nobody | at once | POST /{namespace}/contacts/{contact}/suppress |
When it hands to you
To the AE goes to AE · Priya Shah (person); Not now and every approval come to you, the CSM seat. One Brief per Role, as captured (brief is Not live yet, gaps #7).
[
{
"$id": "https://api.sb/leaseline.app/tasks/task_441" ,
"$type": "Task",
"assignee": "worker_3",
"due": "2026-10-07T16:20:00Z" ,
"on": "customer_58",
"brief": {
"who": "Northgate Residential, free account" ,
"said": "We have 220 units. Is there an annual contract?" ,
"chose": "to-the-ae",
"why": "over 150 units; terms are the AE’s" ,
"never": [
"a discount",
"a deadline that isn’t real"
]
}
},
{
"$id": "https://api.sb/leaseline.app/tasks/task_418" ,
"$type": "Task",
"assignee": "worker_1",
"due": "2026-09-30T17:00:00Z" ,
"on": "customer_51",
"brief": {
"who": "Willow Park, Pro since Sep 21" ,
"chose": "not-now",
"why": "2 support threads on failed imports this week",
"held": "keep-first"
}
},
{
"$id": "https://api.sb/leaseline.app/approvals/approval_52" ,
"$type": "Approval",
"verdict": null,
"deadline": "2026-08-28T15:00:00Z" ,
"draft": "You’ve sent 14 notices this week. Pro gives you 200 lease reads a month for $29…",
"route": "offer-paid-trial",
"checks": [
{
"check": "price",
"status": "pass"
},
{
"check": "keep-first",
"status": "pass"
}
]
}
]The receiver answers with POST /{namespace}/tasks/{task}/claim (Take this one), POST /{namespace}/tasks/{task}/return with a reason, POST /{namespace}/actions/{action}/redirect, POST /{namespace}/approvals/{approval}/approve or POST /{namespace}/approvals/{approval}/refuse, each a one-call window. Once a person claims, the agent’s pending asks to that account cancel with claimedByPerson (Not live yet, gaps #7), and every reply goes to the claimer. A Task nobody claims by its due time goes up a Role through POST /{namespace}/tasks/{task}/escalate, and person-promise fires.
On time: AE · Priya Shah, 1 of 1 within 1 business day, n 1; you, 3 of 3, median 2 h, n 3, ■□□□ Too early to tell beside Other Startups’ 92 % on time. Your queue: in week 1, 20 approvals and 1 weekly review; after the promotion on day 9, 1 weekly review and about 1 hand-off a week.
The fixed line: POST /{namespace}/deals/{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 |
|---|---|---|---|
owned-by-a-person (rule j; gaps #8) | a prompt email to an account whose open Deal a person owns, or whose Conversation a person claimed | Leave it to the person who owns it: the draft waits in their queue. | POST /{namespace}/tasks/{task}/assign |
keep-first (rule j; gaps #8) | an upgrade or win-back ask to a Customer at risk, past due or with an unsubscribe pending | Let their CSM reach them first, then ask again once they are healthy. | POST /{namespace}/tasks/next |
customer-ask-cap (gaps #3) | a second open ask to one Customer in a window, from any Play | usage-upsell holds them until Nov 2. | GET /{namespace}/decisions |
price (gaps #1) | a price, plan or date that differs from the Pricing Page | Quote the Pricing Page’s live Price, or send the ask to the AE. | POST /{namespace}/approvals/{approval}/approve |
exit-never-waits (the record’s Cancel never waits; gaps #3) | any screen, hold or Approval in front of a cancel, pause or downgrade | Remove the step: the cancel completes in one press. | POST /{namespace}/subscriptions/{subscription}/unsubscribe |
abuse (the record’s Abuse stop) | the costly grant to a cluster of more than 3 free accounts in 7 days, or a disposable mailbox | Verify the mailbox or add a card; cheap Verbs keep working. | POST /{namespace}/payment-sources/add |
| Stop (pauses the Play) | Pauses when | Then |
|---|---|---|
pool | month-to-date free-grant cost reaches 100 % of the $50 pool | new visitors see the trial Offer until the 1st; accepted grants stand |
activation-floor | a grant change drops activation 3 points or more below the prior 30 days, at n 100 | the change reverts for new signups (POST /{namespace}/changes/{change}/revert) |
downgrade | an upsell arm’s 60-day downgrade or refund rate passes your half’s | that arm ends |
complaint | complaints near 0.1 % of a week’s prompt emails | emails pause on every Mailbox; in-task prompts keep showing |
kill-switch | you press it | every prompt and email stops; new visitors see the control Offer, accepted Offers stand |
+ 3 more for the checkout lane: refund, day-0 cancel and first charge (?lanes=) |
The captured refusal, then the Stop that fired as a Campaign.paused reason (Not live yet, gaps #2); the rewrite that sent is the Juniper Homes thread in Example.
| Check | Fix | Then |
|---|---|---|
price fail · Not live yet, gaps #1 | Quote the Pricing Page’s live Price, or send the ask to the AE. | POST /{namespace}/approvals/{approval}/approve |
keep-first pass · Not live yet, gaps #8 | — | POST /{namespace}/tasks/next |
JSON
{
"type": "https://startupbuilder.dev/errors/refused",
"title": "Refused",
"status": 422,
"detail": "The upsell to Juniper Homes did not send: it quotes Team at $59, and the Pricing Page says $79.",
"checks": [
{
"check": "price",
"status": "fail",
"doc": "/plays/product-led#check-price",
"fix": "Quote the Pricing Page’s live Price, or send the ask to the AE.",
"then": "Approval.approve"
},
{
"check": "keep-first",
"status": "pass"
}
]
}It says no, says why, and names the fix.
[
{
"reason": {
"limit": "pool",
"observed": "$50.00",
"bound": "$50",
"window": "2026-09"
},
"doc": "/plays/product-led#stop-pool" ,
"task": "task_430"
}
]Did it work
Both halves are read from the same random assignment at signup (rules m–o), and each figure’s call is POST /{namespace}/metrics/query by node and by arm; every node above the leaf is Not live yet (gaps #4).
+18.3 activated per 100 · ■■□□ Early sign · n 164 · path → arr
- Chose
- prompt-at-bound
- Why
- the 10th lease read this month was refused, inside the renewal notice they were drafting, and Pro grants 200
- From
- prompt-at-bound · offer-paid-trial · upsell-month-one · win-back · to-the-ae · not-now
- Expects
- Offer.subscribed on Pro within 14d
- Reopens when
- no Offer.subscribed for customer_44 within 14d
- By
- worker_5
JSON
{
"$id": "decisions/decision_361",
"$type": "Decision",
"choice": "prompt-at-bound",
"reason": "the 10th lease read this month was refused, inside the renewal notice they were drafting, and Pro grants 200",
"stakes": "low",
"options": [
"prompt-at-bound",
"offer-paid-trial",
"upsell-month-one",
"win-back",
"to-the-ae",
"not-now"
],
"by": "worker_5",
"on": "customer_44",
"expected": "Offer.subscribed on Pro within 14d",
"reopensWhen": "no Offer.subscribed for customer_44 within 14d"
}| Node | Agent half | Your half | Added | Confidence | Basis |
|---|---|---|---|---|---|
activation-rate | 50.0 per 100 (41 of 82) | 31.7 (26 of 82) | +18.3 [3.5, 33.1] | ■■□□ Early sign | held back at random · n 164 · 20 still maturing |
new-paying-customers | 17.2 per 100 (11 of 64) | 7.8 (5 of 64) | +9.4 [−2.0, 20.7] · Not carrying up yet | ■□□□ Too early to tell | the same assignment, paid by day 30 · n 128 · 36 still maturing |
new-arr ↑ | $3,817, 11 Customers on Pro · Booked: credited, not all caused by this Play | $1,735, 5 Customers | +$2,082 [−$437, +$4,601] · Worth about +$1,500 a month | ■□□□ Too early to tell (11 vs 5 Customers) | the same · 3 trials still deciding |
net-new-arr · arr | +$3,817 | +$1,735 | +$2,082, carried: it moves no other bridge term | ■□□□ Too early to tell | carried from new-arr |
The leaf counts free signups who sent 2 notices within 14 days per 100 assigned once quarantine has run (rule m), to 150 matured in each half; at 82 and 82 so far the Experiment runs on.
How it moves it: the agent half got a metered grant sized to activation (6 reads, then 10 from day 14) and met the upgrade inside the task at a median of day 6; your half kept today’s free plan and its plain upgrade page.
Guardrails: treated against held back: paid per activated account, 0.27 against 0.19 (new-paying-customers over activation-rate), and refunds in 30 days, 1 of 11 against 0 of 5.
{
"$id": "https://api.sb/leaseline.app/campaigns/product-led" ,
"$type": "Campaign",
"play": "play:product-led@1" ,
"path": [
"activation-rate",
"new-paying-customers",
"new-arr",
"net-new-arr",
"arr"
],
"experiment": {
"metric": "activation-rate",
"unit": "per-100",
"heldBack": 92,
"decision": null
},
"readout": {
"value": 18.3,
"n": 164,
"maturing": 20,
"interval": [
3.5,
33.1
],
"readability": "C",
"basis": "held-back",
"pooled": false
},
"ledger": {
"spend": 38,
"workerCost": 6,
"hours": 1.6,
"pipelineArr": 2400,
"wonArr": 3817,
"projectedArr": 1509,
"paybackDays": 4
}
}Outside benchmark: 0.75 % to 0.83 % of Subscriptions upgrade in their first month (a subscription-analytics vendor’s purchase data, 2026-09-30).
Before it · After it. Before it: programmatic-seo, editorial-seo and free-tools bring the free signups. After it: usage-upsell hears Pro accounts that pace past 50 units, and run-beside holds a new paying Customer’s first 30 days; keep-first holds an account at risk.
Example
Leaseline ran it from 2026-08-24 to 2026-10-19 (56 days). 196 free accounts signed up, 12 were quarantined by the abuse Check, and 184 were assigned at random, 92 to each half; 164 have matured for activation and 128 for paid. Read every captured thread at ?threads=; three follow the tables.
| What happened, agent half, by option | Decisions | Paid | Won (credited, not all caused by this Play) |
|---|---|---|---|
| Prompt at the limit | 38 | 7 to Pro | $2,429 ARR |
| Offer the paid trial | 19 | 4 to Pro, 3 still deciding | $1,388 ARR |
| Upsell in month one · Win back · To the AE · Not now | 6 · 4 · 1 · 3 | 1 to Team · 1 restored · 1 Quote open · 0 | on expansion-arr and reactivated-arr, not here · $2,400 open |
| Agent half · your half | 92 · 92 assigned | 11 of 64 matured · 5 of 64 | $3,817 · $1,735 |
At 20 a month (holdout:) | about 37 in 56 days | basis before-after: Before · Now, not proof | Other Startups’ results: +14.0 per 100, ■■■□ Likely, n 3,410 |
| Day | Event | What happened | Your time |
|---|---|---|---|
| 0 | event_1121 Campaign.created | the three steps; the dry run fails limit-on-offer | 12 min |
| 0 | event_1124 Offer.published | Leaseline Free gains a 6-read monthly Limit | 10 min |
| 3 | event_1170 Offer.subscribed | the first upgrade paid: Oakfield, free to Pro | — |
| 9 | event_1233 Role.updated | promoted: prompts send after a 2-minute hold | — |
| 14 · 44 | event_1262 Change.merged · event_1488 Readout.created | the grant from 6 to 10 reads: replayed at 48.1 per 100, read back at +30d at 50.0, n 82 | 5 min |
| 36 | event_1402 Campaign.paused | pool hit $50 on Sep 29; new visitors saw the trial until Oct 1 | 4 min |
| 1–56 | event_1140 Approval.created, first of 20 | you approved the first wave; price refused 1 draft, rewritten once and sent; weekly reviews, from the POST /{namespace}/tasks/next log | 22 min · total 1.6 h measured, 1.7 h asked |
Oakfield Lettings, in Leaseline, Oct 19 10:12 · the 10th lease read refused, inside the renewal notice they were drafting.
The prompt, in the task · You’ve used 10 of 10 lease reads this month; they reset Nov 1.¹ Pro is $29 a month for 50 units and 200 reads.² Upgrade and this notice finishes now; switching back sits on your billing page.³
Oakfield, 10:14 · pressed Upgrade to Pro.
Offer.subscribed, +$347 ARR.
- Upgrade to Team before Friday and it’s $59 a month.
+ Team is $79 a month and adds the owner report; today’s charge is $41.20, and switching back sits on the same page. Northgate Residential, 2026-10-06 16:20 · “We have 220 units. Is there an annual contract?” · Leaseline, 16:21 · *Thanks. Priya, your account lead, replies by tomorrow 16:20 with the annual plan.*⁴ The Brief went to Priya (
task_441).
¹ The true units left and the reset time, from the Entitlement. ² The next Offer’s Price, from the Pricing Page. ³ The switch down, one press away. ⁴ The routed line, compiled from the Role’s Settings.
Your settings
---
holdout: 0.5 # half the free signups get today’s free plan and upgrade page
---
Give free accounts 10 lease reads a month, 2 a day: twice what activation takes.
In the first 30 paid days, offer Team when a second member is refused the owner report.
Over 150 units, bring in our AE. $ npx startup-builder show plays/product-led
holdout 0.5 · grant 10 reads · Team on a refused member · AE over 150 units your file plays/product-led.mdx:2,4,5,6 (Ask 2026-08-24; line 4 by change_19, 2026-09-07)
worker role:success-manager, owns never · chooses 6 (3 rule, 3 read) · sequences bound-follow-up, trial-to-paid, first-30-upsell, win-back-1-30-60 · approval first-send · checks owned-by-a-person, keep-first, customer-ask-cap, price, exit-never-waits, abuse · stops 8 (5 on in-product and email) · placements gmail, outlook, yahoo-mail, app-store, google-play, chrome-web-store, github, google-search catalog play:product-led@1 (Not live yet, gaps #5) Every key from the catalog
Compiled, not written: edit the Play record product-led and its Leaseline run, then regenerate. Every step runs from a sentence or a line of code. In your words: the CRM words you know, and where each lives here.