Portfolio cross-sell
Shows your other Startup’s Offer inside your product, bill or email, labeled as a sister product, at the moment a Customer needs it.
- Step
- Earn referrals
- Channel
- Ads and sponsorships
- Summary
- startupbuilder.dev
Be our CSM across Keyhold and Leaseline: when a Keyhold Customer hits a renewal job, show them Leaseline on that screen, labeled as our sister product, once. Hold back 1 in 5 Keyhold Customers for 90 days. Send Leaseline’s misfit Leads to Keyhold only when they say yes. 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":"cross-sell"}'import { $ } from 'startup-builder'
return await $.Campaign.create({ play: 'cross-sell' }, { mode: 'try' }) // dry run on the day-0 Startup, 2026-07-21 · Not live yet, gaps #2
npx startup-builder do Campaign.create --json '{"play":"cross-sell"}' --try # Not live yet (gaps #2){
"name": "do",
"arguments": {
"code": "return await $.Campaign.create({ play: 'cross-sell' }, { mode: 'try' }) // dry run on the day-0 Startup, 2026-07-21 · Not live yet, gaps #2",
"mode": "try"
}
}application/json{
"type": "https: //startupbuilder.dev/ errors/ refused",
"title": "Refused",
"status": 422,
"detail": "cross-sell can’t start yet: 1 check fails and 2 warn.",
"checks": [
{
"check": "use-when",
"status": "pass",
"observed": "Keyhold hosts with 412 active Customers; Leaseline has 38 paying from outside; Leaseline hosts misfit routing only (38, under 50)",
"doc": "/plays/ cross-sell#check-use-when",
"then": "Metric.query"
},
{
…
Hand Keyhold’s latest Customer with a renewal job 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(%22Event.captured%22%2C%20%22Subscription.unsubscribed%22%2C%20%22Lead.disqualified%22%2C%20%22Message.scheduled%22%2C%20%22Offer.published%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_1296"}'
# Not live yet (gaps #7)
curl "https://api.sb/leaseline.app/decisions?where=on%20%3D%20%22customer_388%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_388%22" \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY"import { $ } from 'startup-builder'
const { events: [last] } = await $.Event.list({ where: 'name in ("Event.captured", "Subscription.unsubscribed", "Lead.disqualified", "Message.scheduled", "Offer.published")', 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 (\"Event.captured\", \"Subscription.unsubscribed\", \"Lead.disqualified\", \"Message.scheduled\", \"Offer.published\")","limit":1}'
npx startup-builder do Trigger.fire --json '{"like":"event_1296"}' --try # Not live yet (gaps #7)
npx startup-builder do Decision.list --json '{"where":"on = \"customer_388\""}' # Not live yet (gaps #7)
npx startup-builder do Action.list --json '{"where":"on = \"customer_388\""}' # Not live yet (gaps #7){
"name": "do",
"arguments": {
"code": "const { events: [last] } = await $.Event.list({ where: 'name in (\"Event.captured\", \"Subscription.unsubscribed\", \"Lead.disqualified\", \"Message.scheduled\", \"Offer.published\")', 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_671",
"$type": "Decision",
"choice": "sister-card",
"reason": "Pine Street Rentals, a Keyhold Customer, exported 14 lease end dates to a spreadsheet on this screen; the need map gives that job to Leaseline, and they sit in the exposed 80 %",
"stakes": "low",
"options": [
"sister-card",
"sister-note",
"after-the-cancel",
"route-the-misfit",
"not-now",
"leave-it"
…
sample · 4 Not live yetPreviewOn PATCH /{namespace}/campaigns/{id}Off PATCH /{namespace}/campaigns/{id}Take this one POST /{namespace}/tasks/{task}/claim
action_1 reads the holdout flag: Pine Street is in the exposed 80 %. action_2 records the card shown beside the export, labeled as a sister product, with Leaseline’s live Price.
+1.20 Leaseline Customers per 100 Keyhold Customers · ■□□□ Too early to tell · n 412 · path → arr
| Try another: the host Customer | Choice | Reason |
|---|---|---|
| Pine Street Rentals (Keyhold): exported 14 lease end dates to a spreadsheet | sister-card | the job on screen is one the need map gives Leaseline |
| Oak Lane Homes (Keyhold): cancelled, “we need renewal reminders, not deposits” | after-the-cancel | the cancel completed first; the card follows it |
| A Leaseline Lead with 1 unit asking only about deposit tracking | route-the-misfit | fits no Leaseline Offer; fits Keyhold; asks Yes or No thanks first |
| Birchwood Lets (Keyhold): at risk since Oct 5 | not-now | keep-first: their CSM sees them first |
- Day 6: the first Customer sent by Keyhold
- First signal
Where it sits: Stranger → Lead → Trialing → Activated → At risk → Churned → Won back · Sibling acquisitions → Sent by your other Startups → New Customers ↑ → ARR added each week → ARR
Worth’s formula: about +1.20 Leaseline Customers per 100 Keyhold Customers a quarter × 412 ÷ 3 months × $348 first order ARR ≈ +$573. Under First signal: The 20 % of Keyhold Customers held back reads at 90 days and 500 Customers; at 412 it reads Too early to tell until more join.
sample · api.sb/
Also runs as: House ads.
Start it this week
npx startup-builder add play cross-sell # 1 · writes plays/cross-sell.mdx (Not live yet, gaps #5)
# 2 · dry run; below, its 422 on 2026-07-21
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":"cross-sell"}'
# 3 · keep it, at You approve each one
curl -X POST https://api.sb/leaseline.app/campaigns \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"play":"cross-sell"}'
# on
curl -X PATCH https://api.sb/leaseline.app/campaigns/cross-sell \
-H "Authorization: Bearer $STARTUP_BUILDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endsAt":null}'
# off: no card, slot or note renders; routed intros already sent stay
curl -X PATCH https://api.sb/leaseline.app/campaigns/cross-sell \
-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 | Keyhold hosts with 412 active Customers; Leaseline has 38 paying from outside; Leaseline hosts misfit routing only (38, under 50) | POST /{namespace}/metrics/query |
billing-connected fail · Not live yet, gaps #8 | Connect Stripe on Keyhold so a sister Customer’s subscription reads live. | POST /{namespace}/integrations/connect |
baseline warn · Not live yet, gaps #8 | n 3: the held-back 20 % becomes the comparison at 90 days. | POST /{namespace}/metrics/query |
role-unstaffed warn · Not live yet, gaps #8 | Leave it: exclusion audits come to you; 9 Customers a person owns get drafts. | POST /{namespace}/roles/{role}/staff |
JSON
{
"type": "https://startupbuilder.dev/errors/refused",
"title": "Refused",
"status": 422,
"detail": "cross-sell can’t start yet: 1 check fails and 2 warn.",
"checks": [
{
"check": "use-when",
"status": "pass",
"observed": "Keyhold hosts with 412 active Customers; Leaseline has 38 paying from outside; Leaseline hosts misfit routing only (38, under 50)",
"doc": "/plays/cross-sell#check-use-when",
"then": "Metric.query"
},
{
"check": "billing-connected",
"status": "fail",
"doc": "/plays/cross-sell#check-billing-connected",
"fix": "Connect Stripe on Keyhold so a sister Customer’s subscription reads live.",
"then": "Integration.connect"
},
{
"check": "baseline",
"status": "warn",
"observed": "3 Leaseline Customers from Keyhold Customers in 90 days, unprompted",
"doc": "/plays/cross-sell#check-baseline",
"fix": "n 3: the held-back 20 % becomes the comparison at 90 days.",
"then": "Metric.query"
},
{
"check": "role-unstaffed",
"status": "warn",
"roles": [
"privacy-officer"
],
"owned": 9,
"doc": "/plays/cross-sell#check-role-unstaffed",
"fix": "Leave it: exclusion audits come to you; 9 Customers a person owns get drafts.",
"then": "Role.staff"
}
]
}It says no, says why, and names the fix.
| Check | Fix (one call) | Your time | Wait |
|---|---|---|---|
use-when · pass: Keyhold 412 active; Leaseline routes misfits only (gaps #8) | POST /{namespace}/metrics/query on sibling-acquisitions | 1 min | none |
billing-connected · fail | POST /{namespace}/integrations/connect with system recurring-billing (Stripe) on Keyhold | 5 min | the first sync, about 10 min |
baseline · warn: 3 unprompted in 90 days | POST /{namespace}/metrics/query; it fills Before in Your number | 0 min | 90 days for the held-back read |
role-unstaffed · warn (gaps #8): 9 Keyhold Customers have a person owner | POST /{namespace}/roles/{role}/staff, or leave it: their cards draft into the owner’s queue | 0 min | none |
Your number · Before 3 in 90 days,
Cards, receipt lines and newsletter slots ride Keyhold’s own surfaces at no cost, and a sister note costs $0.04 (Worker cost). Exclusion Audiences on each Startup’s Ads cost nothing. That comes to about $1 a month.
When it starts
| When | Trigger id | Starts on | Window | How it gets in |
|---|---|---|---|---|
| A host Customer’s screen shows a job your sister Startup does | adjacent-need | event on Event.captured where a support tag, search, export or plan limit the need map gives the sister Startup | 14d | Keyhold’s app calls POST /{namespace}/events/capture on the job |
| A host Customer cancels over a capability the sister has | cancel-gap | event on Subscription.unsubscribed where the cancel or downgrade names a capability the sister’s live Offer delivers | 7d | POST /{namespace}/subscriptions/{subscription}/unsubscribe; the cancel completes first |
| The host schedules a newsletter issue | host-issue-scheduled | event on Message.scheduled where a host newsletter issue, which opens one sponsored slot | 2d | POST /{namespace}/messages/{message}/schedule on Keyhold’s newsletter |
+ 3 more for misfit Leads, sister Offer changes and Studio sign-ins (?lanes=) |
{
"$id": "https://api.sb/leaseline.app/events/event_1296" ,
"$type": "Event",
"name": "Event.captured",
"at": "2026-10-19T13:22:51Z" ,
"seq": 1296,
"data": {
"startup": "keyhold",
"customer": "customer_388",
"name": "Pine Street Rentals",
"job": "export lease end dates",
"rows": 14,
"needMap": "leaseline: renewal notices"
}
}npx startup-builder trigger Event.captured --like event_1296 # replays the delivery
npx startup-builder clock advance --by 30d # a second need after 30 days may show the card again
npx startup-builder listen Event.captured --forward-to http://localhost:3000/hook Who runs it
CSM · your agent (role:, worker_5) holds the Key Result across the pair. Misfit Leads it routes go to Keyhold’s SDR · your agent after the Lead says yes, with the 5-minute answer, and a held Customer stays with their CSM. You (Dana Okafor, person, worker_1) own both Startups and approved the pair. Your number links to Did it work.
| Option | Rung today | Evidence (passed of n) | Moves up at | Drops back when | You can |
|---|---|---|---|---|---|
| Show the sister card · Card after the cancel | It does it, you can stop it | 211 of 211 · 6 of 6 | met on day 14 (the first 20 reviewed) | a render with no exclusion record, or dismissal fires → You approve each one | POST /{namespace}/actions/{action}/cancel inside the hold |
| Send the sister note | You approve each one | 4 of 4 | at 20 approved | — | POST /{namespace}/approvals/{approval}/approve · POST /{namespace}/approvals/{approval}/refuse |
| Route the misfit | It does it | 9 asked, 5 yes | the fixed copy passed its replay | consent-stop → You approve each one | POST /{namespace}/actions/{action}/redirect |
| Not now · Leave it | It does it | 7 · 12 | — | — | POST /{namespace}/actions/{action}/redirect |
| Change the pair or the need map | You approve each one | 1 merged (a new need entry) | never | — | POST /{namespace}/changes/{change}/merge · POST /{namespace}/changes/{change}/revert |
| Never: a modal, a countdown, a card on a receipt email, a word about what they did elsewhere | Refused, every time | 1 note refused (knowledge) | never | — | — |
What it reads · What it may call. It reads the job on the Customer’s current screen, a need they stated in their own words, and the pair’s exclusion records; it never reads or names what a Person did at the other Startup. Its tools are POST /{namespace}/flags/{flag}/evaluate, POST /{namespace}/events/capture, POST /{namespace}/messages/{message}/schedule, and any other call becomes an Approval under the Role’s Mandate.
You hold the CSM Key Result: +1.5 Leaseline Customers per 100 Keyhold Customers at the 500-Customer read. ← worker, metric
Label every sister Offer with the sister label and Leaseline’s name, visible without a click. ← copyRules 1, 2
Name only the job on the current screen, or a need they stated themselves. ← copyRules 4, 5
A cancel always completes first; the card never delays or argues with it. ← copyRules 9
Ask a misfit Yes or No thanks, equally, before anything passes to Keyhold. ← copyRules 12, consent-stop The slow loop, as a Change. change_19 (Aug 30) added “export lease end dates” to the need map after 9 Pine-Street-like exports in a week; you merged it. Replayed on the past 30 days it would have shown 31 more cards; read back at +30d, 2 of 31 subscribed, n 31, ■□□□ Too early to tell.
What it can do next
| Option | Picked when | Decided by | Runs | Hands to | If it’s just you |
|---|---|---|---|---|---|
Show the sister card sister-card | the job on screen is one the need map gives the sister | Your rule (the need map, line 4) | POST /{namespace}/flags/{flag}/evaluate the holdout → POST /{namespace}/events/capture the card shown | the Customer’s own click | the same |
Send the sister note sister-note | a need stated in their own words, in a Keyhold email | Agent’s call, with its reason | sister-note, once, from Keyhold | you approve it · Take this one | the same |
Card after the cancel after-the-cancel | a cancel over a capability Leaseline has | Your rule (copyRules 9) | POST /{namespace}/flags/{flag}/evaluate, after the cancel completes | nobody | the same |
Route the misfit route-the-misfit | a Lead that fits no Leaseline Offer and fits Keyhold | Agent’s call, with its reason | POST /{namespace}/messages/{message}/schedule the Yes or No thanks ask → on yes, misfit-intro | Keyhold’s SDR · your agent, due 5 min after yes | you |
Not now not-now | another Play holds them, or keep-first applies | Your rule (rule j) | nothing; names the holder | their CSM · Take this one | you |
Leave it leave-it | they chose Hide sister products, or no exclusion record | Your rule (the Exclusion audit) | nothing; nothing renders | nobody | the same |
| When they write back | Fixture reply | What happens | Who answers | By when | Call |
|---|---|---|---|---|---|
| Interested | “Yes, introduce us to Keyhold.” | Keyhold’s first Message names Leaseline as referrer in its first line, within 5 minutes | Keyhold’s SDR · your agent | 5 min | POST /{namespace}/leads/{lead}/assign |
| Send me pricing | “What does Leaseline cost on top of Keyhold?” | Leaseline’s list Price from its Pricing Page, with the sister label | CSM · your agent | 1 business hour | POST /{namespace}/conversations/{conversation}/answer |
| Not now | “Maybe after renewal season.” | no card or note for 30 days; customer-ask-cap frees them | nobody | at once | GET /{namespace}/decisions |
| Wrong person | “My partner handles the leases.” | 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 28 | the note waits for the return date | nobody | the return date | POST /{namespace}/messages/{message}/schedule |
| Question it can’t answer | “Can you bundle both for one price?” | To a person: a bundle price is never the agent’s call | you | 1 business day | POST /{namespace}/conversations/{conversation}/escalate |
| Stop | pressed Hide sister products | no sister Offer shows to them again, on any surface | nobody | at once | POST /{namespace}/contacts/{contact}/unsubscribe |
When it hands to you
Routed misfits go to Keyhold’s SDR · your agent after the Lead says yes; a held Customer stays with their CSM, and sister notes and bundle questions come to you (Dana Okafor, person). One Brief per Role, as captured (brief is Not live yet, gaps #7).
[
{
"$id": "https://api.sb/leaseline.app/tasks/task_681" ,
"$type": "Task",
"assignee": "worker_12",
"due": "2026-10-08T15:05:00Z" ,
"on": "lead_47",
"brief": {
"who": "Marta Diaz, 1 unit, asked Leaseline about deposit tracking" ,
"said": "I just need to track one deposit and its return date",
"chose": "route-the-misfit",
"why": "fits no Leaseline Offer; fits Keyhold’s free tier" ,
"consent": "consent_77, naming Keyhold" ,
"firstLine": "names Leaseline as referrer"
}
},
{
"$id": "https://api.sb/leaseline.app/tasks/task_676" ,
"$type": "Task",
"assignee": "worker_5",
"due": "2026-10-06T17:00:00Z" ,
"on": "customer_401",
"brief": {
"who": "Birchwood Lets, a Keyhold Customer" ,
"chose": "not-now",
"why": "at risk since Oct 5: 2 failed payments" ,
"held": "keep-first"
}
}
]Each receiver answers with POST /{namespace}/tasks/{task}/claim (Take this one), POST /{namespace}/tasks/{task}/return with a reason, or POST /{namespace}/actions/{action}/redirect. Once a person claims, no card or note renders for that Customer, and every reply goes to the claimer. A routed Lead unanswered in 5 minutes counts toward routing, and a Task nobody claims by its due time goes up through POST /{namespace}/tasks/{task}/escalate.
On time: Keyhold’s SDR · your agent, 5 of 5 answered within 5 minutes, median 1m 40s; CSM, 7 of 7; n 12, ■□□□ Too early to tell beside Other Startups’ 96 % on time. Your queue: counted by kind: about 1 sister note to approve a week and 1 bundle question a month.
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; Not live yet, gaps #8) | a card or note to a Customer whose open Deal or Conversation a person owns | 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) | a sister Offer to a Customer who is at risk, past due or leaving | 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 3. | GET /{namespace}/decisions |
consent-stop (gaps #3) | an intro to Keyhold the Lead has not said yes to | Ask once for a yes to the other Startup. | POST /{namespace}/messages/{message}/schedule |
knowledge (the record’s Knowledge hold) | a card, slot, note or intro that refers to what a Person did at the other Startup | Name only the job on screen, or their own words. | POST /{namespace}/content/draft |
The captured refusal, then the Stop that fired as a Campaign.paused reason (Not live yet, gaps #2); the rewrite that sent is the Cedar Mews thread in Example.
| Check | Fix | Then |
|---|---|---|
knowledge fail | Name only the job on screen, or their own words. | POST /{namespace}/content/draft |
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 sister note to Cedar Mews did not send: it refers to what they did at Keyhold’s deposit screen.",
"checks": [
{
"check": "knowledge",
"status": "fail",
"doc": "/plays/cross-sell#check-knowledge",
"fix": "Name only the job on screen, or their own words.",
"then": "Content.draft"
},
{
"check": "keep-first",
"status": "pass"
}
]
}It says no, says why, and names the fix.
[
{
"reason": {
"limit": "exclusion-audit",
"observed": "1 render with no exclusion record",
"bound": "0",
"window": "1 render"
},
"doc": "/plays/cross-sell#stop-exclusion-audit" ,
"task": "task_633"
}
]Did it work
Keyhold’s Customers were split at random, 80 % exposed and 20 % held back for 90 days, so both halves read from the same assignment (rules m–o). The read reaches its minimum at 500 Customers, so it prints Too early to tell with its counts. Each figure’s call is POST /{namespace}/metrics/query by node, and every node above the leaf is Not live yet (gaps #4).
+1.20 Leaseline Customers per 100 Keyhold Customers · ■□□□ Too early to tell · n 412 · path → arr
- Chose
- sister-card
- Why
- Pine Street Rentals, a Keyhold Customer, exported 14 lease end dates to a spreadsheet on this screen; the need map gives that job to Leaseline, and they sit in the exposed 80 %
- From
- sister-card · sister-note · after-the-cancel · route-the-misfit · not-now · leave-it
- Expects
- Offer.subscribed at Leaseline within 90d
- Reopens when
- the Customer chooses Hide sister products, or a second adjacent need after 30d
- By
- worker_5
JSON
{
"$id": "decisions/decision_671",
"$type": "Decision",
"choice": "sister-card",
"reason": "Pine Street Rentals, a Keyhold Customer, exported 14 lease end dates to a spreadsheet on this screen; the need map gives that job to Leaseline, and they sit in the exposed 80 %",
"stakes": "low",
"options": [
"sister-card",
"sister-note",
"after-the-cancel",
"route-the-misfit",
"not-now",
"leave-it"
],
"by": "worker_5",
"on": "customer_388",
"expected": "Offer.subscribed at Leaseline within 90d",
"reopensWhen": "the Customer chooses Hide sister products, or a second adjacent need after 30d"
}| Node | Exposed (330) | Held back (82) | Added | Confidence | Basis |
|---|---|---|---|---|---|
sibling-acquisitions | 2.42 Leaseline Customers per 100 (8 of 330) | 1.22 (1 of 82) | +1.20 [−1.6, +4.0] | ■□□□ Too early to tell | held back at random · n 412 of the 500 minimum · Other Startups’ results +0.9 per 100, ■■■□ Likely, n 9,400 |
sibling-sourced-share ↑ | 8 Leaseline Customers sent by Keyhold | 4 expected (1 × 4) | +4 [−3, +11] · Not carrying up yet | ■□□□ Too early to tell | the same assignment |
new-arr ↑ | $2,784, 8 Customers · Booked $3,132: credited, not all caused by this Play (1 routed misfit at Keyhold) | $1,392, 4 expected | +$1,392 [−$2,400, +$5,200] · Worth about +$570 a month | ■□□□ Too early to tell (8 vs 4) | the same · 3 trials still deciding |
net-new-arr · arr | +$2,784 | +$1,392 | +$1,392, carried | ■□□□ Too early to tell | carried from new-arr |
The leaf counts Leaseline Customers won per 100 exposed Keyhold Customers over 90 days, less the rate in the held-back Customers, whoever reached them; misfit routing reads one host-week in 5 held back, separately.
How it moves it: a labeled card beside the job on screen, once, with Leaseline’s live Price; 211 cards shown, 8 subscribed.
Guardrails: treated against held back: Keyhold’s own churn (churned-arr), 2.1 against 1.6 per 100, inside the 1-point Stop, and Hide sister products, 1.4 % of those shown.
{
"$id": "https://api.sb/leaseline.app/campaigns/cross-sell" ,
"$type": "Campaign",
"play": "play:cross-sell@1" ,
"path": [
"sibling-acquisitions",
"sibling-sourced-share",
"new-arr",
"net-new-arr",
"arr"
],
"experiment": {
"metric": "sibling-acquisitions",
"unit": "per-100",
"heldBack": 82,
"decision": null
},
"readout": {
"value": 1.2,
"n": 412,
"maturing": 0,
"interval": [
-1.6,
4
],
"readability": "D",
"basis": "held-back",
"pooled": false
},
"ledger": {
"spend": 0,
"workerCost": 1,
"hours": 1.9,
"pipelineArr": 1044,
"wonArr": 3132,
"projectedArr": 573,
"paybackDays": 0
}
}Outside benchmark: none recorded for this Play yet; Other Startups’ results print beside every row.
Before it · After it. Before it: instant-response grades each Lead’s fit and hands misfits here (Lead.disqualified), and owned-newsletter opens the slot. After it: usage-upsell and customer-referrals take the sister Customers once they’re active; each readout seeds the next pair’s estimate. house-ads runs inside it.
Example
Leaseline ran it from 2026-07-21 to 2026-10-19 (90 days) on one pair: Keyhold (412 active Customers, 330 exposed and 82 held back) hosted Leaseline’s sister Offer, and Leaseline routed 9 misfit Leads to Keyhold. Read every captured thread at ?threads=; three follow the tables.
| What happened, by option | Decisions | Shown or sent | Customers (Booked: credited, not all caused by this Play) |
|---|---|---|---|
| Show the sister card · Card after the cancel | 211 · 6 | 211 cards · 6 after cancels | 7 · 1 |
| Send the sister note | 4 | 4 notes, 1 rewritten after knowledge | 0 |
| Route the misfit | 9 | 9 asked, 5 yes, 5 intros in 5 min | 1 at Keyhold |
| Not now · Leave it | 7 · 12 | — | — |
| At 50 host Customers (the Use it when volume) | 40 exposed, 10 held back | reads after about 3 years of 90-day windows | Other Startups’ results: +0.9 per 100, ■■■□ Likely, n 9,400 |
| Day | Event | What happened | Your time |
|---|---|---|---|
| 0 | event_610 Campaign.created | the three steps; the dry run fails billing-connected on Keyhold | 20 min |
| 0 | event_611 Integration.connected | Stripe on Keyhold; you approve the pair and its need map | 15 min |
| 6 | event_688 Offer.subscribed | the first Leaseline Customer from a Keyhold card | — |
| 14 | event_740 Role.updated | promoted: cards render after a 2-minute hold | — |
| 23 | event_801 Campaign.paused | exclusion-audit: one render had no exclusion record; fixed and resumed in 2 h | 15 min |
| 40 | event_912 Change.merged | change_19: “export lease end dates” joins the need map | 5 min |
| 62 | event_1101 Action.refused | knowledge refused a sister note to Cedar Mews; the rewrite sent | — |
| 1–90 | event_640 Approval.created, first of 4 | you approved 4 notes; weekly reviews, from the POST /{namespace}/tasks/next log | 40 min · total 1.9 h measured, 2 h asked |
Keyhold, beside the export button, Oct 19, 13:22 · *Sister product, same owner: Leaseline.*¹ Tracking lease end dates? Leaseline sends each renewal notice 60 days out, per owner: $29 a month.² Try Leaseline · Hide sister products³
Pine Street Rentals, 13:31 · opened Leaseline’s trial from the card (
Offer.trialed).
- We saw you export your deposit dates every Monday; Leaseline can do the renewals too.
+ You asked about renewal reminders last week. Leaseline, our sister product, sends each notice 60 days out: $29 a month. Marta Diaz, to Leaseline, 2026-10-08 15:00 · “I just need to track one deposit and when it’s due back.” · SDR · your agent, 15:00 · asked Yes or No thanks to an intro to Keyhold, our sister product. · Marta, 15:02 · Yes. The Brief went to Keyhold’s SDR agent (
task_681), due 15:05; its first Message, naming Leaseline as referrer, went at 15:03.
¹ The sister label and the receiver’s name, visible without a click. ² The job on the current screen and the live Price from Leaseline’s Pricing Page. ³ Not a modal; it never covers the host’s own action.
Your settings
---
pair: keyhold → leaseline
holdout: 0.2 # 1 in 5 Keyhold Customers sees no sister Offer for 90 days
needs: [export lease end dates, renewal reminders]
---
Show Leaseline once, labeled as our sister product, beside a renewal job in Keyhold. Route Leaseline’s misfit Leads to Keyhold only on a yes. $ npx startup-builder show plays/cross-sell
pair keyhold → leaseline · holdout 0.2 · needs 2 your file plays/cross-sell.mdx:2-4,6 (Ask 2026-07-21; need by change_19, 2026-08-30)
family referral · also runs house-ads · worker role:success-manager, owns never · chooses 6 (4 rule, 2 read) · sequences misfit-intro, sister-note · checks owned-by-a-person, keep-first, customer-ask-cap, consent-stop, knowledge · stops 8 · placements portfolio-network, gmail, outlook, yahoo-mail, facebook, linkedin, google-search, google-display (exclusions) catalog play:cross-sell@1 (Not live yet, gaps #5) Every key from the catalog
Compiled, not written: edit the Play record cross-sell 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.