URL: /plays/cross-sell · Id: `play:cross-sell`

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

Shows your other [Startup](https://startupbuilder.dev/business/nouns/startup)’s [Offer](https://startupbuilder.dev/business/nouns/offer) inside your product, bill or email, labeled as a sister product, at the moment a [Customer](https://startupbuilder.dev/business/nouns/customer) needs it.

```text title="Set it up · Ask"
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.
```

```text title="Run one · Ask"
Hand Keyhold’s latest Customer with a renewal job to the CSM agent and show me what it chose and why. Don’t send.
```

```ts title="Run one · SDK"
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 }
```

```json title="Run one · Response · sample"
{
  "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"
    ],
    "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"
  },
  "actions": [
    {
      "$id": "https://api.sb/leaseline.app/actions/action_1",
      "$type": "Action",
      "status": "done",
      "mode": "try",
      "by": "worker_5",
      "verb": "Flag.evaluate",
      "checks": []
    },
    {
      "$id": "https://api.sb/leaseline.app/actions/action_2",
      "$type": "Action",
      "status": "done",
      "mode": "try",
      "by": "worker_5",
      "verb": "Event.capture",
      "checks": [
        {
          "check": "owned-by-a-person",
          "status": "pass"
        },
        {
          "check": "keep-first",
          "status": "pass"
        },
        {
          "check": "customer-ask-cap",
          "status": "pass"
        },
        {
          "check": "knowledge",
          "status": "pass"
        }
      ]
    }
  ]
}
```

`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 |

| Worth | Your time | Spend | First signal | Use it when | Instead |
| --- | --- | --- | --- | --- | --- |
| ≈ +$570 ARR a month at 412 host Customers | 20 min for the pair, then 10 min a week | $0: cards, slots and notes ride the host’s own surfaces | Day 6: the first Customer sent by Keyhold | 50+ host Customers, a sister Startup with 10+ paying | Under 50 at the host: route misfits only (<Op verb="Lead.assign" />) · one Startup: `customer-referrals` |

**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/leaseline.app · 41 Customers at 2026-10-19 · on Event.captured · first card at the next screen, 0.4 s · 9 keys Not live yet`

**Also runs as:** [House ads](/plays/house-ads).

## Start it this week
```bash
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"}'
```


```json title="The dry run’s 422 · 2026-07-21 · sample"
{
  "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"
    }
  ]
}
```

| Check | Fix (one call) | Your time | Wait |
| --- | --- | --- | --- |
| <a id="check-use-when"></a>`use-when` · pass: Keyhold 412 active; Leaseline routes misfits only (gaps #8) | <Op verb="Metric.query" /> on `sibling-acquisitions` | 1 min | none |
| <a id="check-billing-connected"></a>`billing-connected` · fail | <Op verb="Integration.connect" /> with `system` recurring-billing (Stripe) on Keyhold | 5 min | the first sync, about 10 min |
| <a id="check-baseline"></a>`baseline` · warn: 3 unprompted in 90 days | <Op verb="Metric.query" />; it fills Before in Your number | 0 min | 90 days for the held-back read |
| <a id="check-role-unstaffed"></a>`role-unstaffed` · warn (gaps #8): 9 Keyhold Customers have a person owner | <Op verb="Role.staff" />, or leave it: their cards draft into the owner’s queue | 0 min | none |

`Your number · Before 3 in 90 days, unprompted · Now +1.20 Leaseline Customers per 100 Keyhold Customers · Target +1.5 at the 500-Customer read · ■□□□ Too early to tell · n 412`

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 <Op verb="Event.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 | <Op verb="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 | <Op verb="Message.schedule" /> on Keyhold’s newsletter |
| + 3 more for misfit Leads, sister Offer changes and Studio sign-ins (`?lanes=all`) | | | | |

```json title="One signed delivery · StartupBuilder-Signature"
{
  "$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"
  }
}
```

```bash title="Reproduce it in test"
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:success-manager`, `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 | <Op verb="Action.cancel" /> inside the hold |
| Send the sister note | You approve each one | 4 of 4 | at 20 approved | — | <Op verb="Approval.approve" /> · <Op verb="Approval.refuse" /> |
| Route the misfit | It does it | 9 asked, 5 yes | the fixed copy passed its replay | `consent-stop` → You approve each one | <Op verb="Action.redirect" /> |
| Not now · Leave it | It does it | 7 · 12 | — | — | <Op verb="Action.redirect" /> |
| Change the pair or the need map | You approve each one | 1 merged (a new need entry) | never | — | <Op verb="Change.merge" /> · <Op verb="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 <Op verb="Flag.evaluate" />, <Op verb="Event.capture" />, <Op verb="Message.schedule" />, and any other call becomes an Approval under the Role’s Mandate.

```md title="The brief (folded): compiled Agent-register Markdown · play:cross-sell@1 · each line names its record key"
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) | <Op verb="Flag.evaluate" /> the holdout → <Op verb="Event.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) | <Op verb="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 | <Op verb="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 | <Op verb="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 | <Op verb="Conversation.answer" /> |
| Not now | “Maybe after renewal season.” | no card or note for 30 days; `customer-ask-cap` frees them | nobody | at once | <Op verb="Decision.list" /> |
| 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 | <Op verb="Conversation.answer" /> |
| Out of office | an auto-reply until Oct 28 | the note waits for the return date | nobody | the return date | <Op verb="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 | <Op verb="Conversation.escalate" /> |
| Stop | pressed Hide sister products | no sister Offer shows to them again, on any surface | nobody | at once | <Op verb="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).

```json title="What lands in each queue · sample"
[
  {
    "$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 <Op verb="Task.claim" /> (Take this one), <Op verb="Task.return" /> with a reason, or <Op verb="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 <Op verb="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:** <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 |
| --- | --- | --- | --- |
| <a id="check-owned-by-a-person"></a>`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. | <Op verb="Task.assign" /> |
| <a id="check-keep-first"></a>`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. | <Op verb="Task.next" /> |
| <a id="check-customer-ask-cap"></a>`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. | <Op verb="Decision.list" /> |
| <a id="check-consent-stop"></a>`consent-stop` (gaps #3) | an intro to Keyhold the Lead has not said yes to | Ask once for a yes to the other Startup. | <Op verb="Message.schedule" /> |
| <a id="check-knowledge"></a>`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. | <Op verb="Content.draft" /> |

| Stop (pauses the Play) | Pauses when | Then |
| --- | --- | --- |
| <a id="stop-exclusion-audit"></a>`exclusion-audit` | any render or Message with no exclusion record | every step pauses at once |
| <a id="stop-host-churn"></a>`host-churn` | at 90 days, exposed host churn over the held back by 1 per 100 | the pair’s sister Offers pause |
| <a id="stop-dismissal"></a>`dismissal` | Hide sister products chosen by over 3 % of those shown, over 300 in 30 days | the pair pauses |
| <a id="stop-complaint"></a>`complaint` | spam complaints near 0.1 % of a day’s slots or notes | sister email pauses on every host Mailbox |
| <a id="stop-opt-out"></a>`opt-out` | issues with a slot drawing unsubscribes 0.5 points above those without, over 4 issues | the slot drops |
| <a id="stop-yield-floor"></a>`yield-floor` | the acquisitions lift less the host churn lift at or under 0 at 90 days | the pair’s sister Offers end; a Decision opens |
| <a id="stop-kill-switch"></a>`kill-switch` | you press it | every step pauses on every pair |
| + 1 more for the routing lane: a sister that misses the 5-minute answer (`?lanes=all`) | | |

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.

```json title="The captured refusal · sample"
{
  "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"
    }
  ]
}
```


```json title="The Stops that fired, as Campaign.paused reasons · sample"
[
  {
    "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 <Op verb="Metric.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`

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

```json title="The Campaign card · sample"
{
  "$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=all`; 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 <Op verb="Task.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`).

```diff title="Cedar Mews, 2026-09-21 · the sister note knowledge refused, then the rewrite that sent"
- 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
```mdx title="plays/cross-sell.mdx"
---
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.
```

```text
$ 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
| Key | Id | What it is |
| --- | --- | --- |
| Tactic | <a id="tactic-sibling-pairing"></a>`sibling-pairing` | Sibling pairing scores ICP overlap between Studio-held siblings each week and writes, for each kept pair, the host jobs the receiver’s Offer serves. Starts at You approve the first wave. |
| Tactic | <a id="tactic-sibling-exclusion"></a>`sibling-exclusion` | Sibling exclusion decides, before every sibling render, Message and Audience sync, whether a Person may see a receiver’s Offer, and logs the reason. Starts at Sends nothing. |
| Tactic | <a id="tactic-sister-offer"></a>`sister-offer` | Sister offer shows one labeled, dismissible card for a paired sibling in the host’s Product or hosted Invoice Page when a need shows. Starts at You approve each one. |
| Tactic | <a id="tactic-misfit-routing"></a>`misfit-routing` | Misfit routing answers a Lead the host cannot serve, asks once to introduce the fitting sibling, and on a yes hands over the Lead. Starts at Sends nothing. |
| Tactic | <a id="tactic-studio-sign-in"></a>`studio-sign-in` | Studio sign-in offers one visible Studio account at a sibling’s sign-up and checkout, only on Studio Brands, and carries identity and wallet across. Starts at Sends nothing. |
| Tactic | <a id="tactic-sister-slot"></a>`sister-slot` | Sister slot places one labeled sibling Offer in the host’s newsletter issue and one host-signed sister note per Person per pair per 90 days. Starts at You approve each one. |
| Tactic | <a id="tactic-cross-sell-readout"></a>`cross-sell-readout` | Cross-sell readout nets each pair’s receiver Customers against host churn, both against the holdout, and pauses pairs that cost more than they win. Starts at Sends nothing. |
| Trigger | <a id="trigger-adjacent-need"></a>`adjacent-need` | Adjacent need: `event` on `Event.captured`, window 14d. |
| Trigger | <a id="trigger-cancel-gap"></a>`cancel-gap` | Cancel gap: `event` on `Subscription.unsubscribed`, window 7d. |
| Trigger | <a id="trigger-misfit-lead"></a>`misfit-lead` | Misfit lead: `event` on `Lead.disqualified`, window 1d. |
| Trigger | <a id="trigger-host-issue-scheduled"></a>`host-issue-scheduled` | Host issue scheduled: `event` on `Message.scheduled`, window 2d. |
| Trigger | <a id="trigger-sibling-offer-change"></a>`sibling-offer-change` | Sibling offer change: `event` on `Offer.published`, window 7d. |
| Trigger | <a id="trigger-studio-sign-in-start"></a>`studio-sign-in-start` | Studio sign-in start: `event` on `Event.captured`, window 1h. |
| Sequence | <a id="sequence-misfit-intro"></a>`misfit-intro` | Misfit intro: 3 steps; it ends on The Person replies.; The Person books a Meeting.; The Person signs up at the receiver.; The Person withdraws Consent or is suppressed.; 7 days pass from the intro.. |
| Sequence | <a id="sequence-sister-note"></a>`sister-note` | Sister note: 1 steps; it ends on The note is sent.; The Person is suppressed or unsubscribes before the send.. |