URL: /capabilities/keep-and-grow/save-and-win-back · Id: `page:/capabilities/keep-and-grow/save-and-win-back`

# Save and win back

> A customer at risk in, and within 24 hours one note out with the offer your Policy allows that fits their usage.

A [customer](https://startupbuilder.dev/business/nouns/customer) at risk in, and within 24 hours one note out with the offer your [Policy](https://startupbuilder.dev/business/nouns/policy) allows that fits their usage.

```text title="Run it · Ask"
Try to save the five customers who went quiet, spend up to $1, and show me the plan first.
```

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

const result = await $.try.Customer.save({ goal: 'Try to save the five customers who went quiet, spend up to $1, and show me the plan first.', budget: { amount: 1, currency: 'USD' } })
```

```json title="Run it · Response"
{
  "mode": "try",
  "stored": false,
  "estimate": "About 0.7¢ a Customer",
  "sample": {
    "customer": "Cedar Hollow Rentals",
    "offer": "pause",
    "note": "You tracked 4 renewals in September and none since. Pause for 60 days instead of cancelling?",
    "outcome": "paused"
  }
}
```

- **Kind:** Agent task
- **Price:** 0.7¢ per customer
- **Your time:** 13 minutes
- **Approval:** Runs on its own
- **Deepens:** [`workflow:save-and-win-back`](https://startupbuilder.dev/workflows/processes/retention/save-and-win-back)

## What you get
- **1 customer** · A customer at risk in, and within 24 hours one note out with the offer your Policy allows that fits their usage. · [`noun:customer`](https://startupbuilder.dev/business/nouns/customer)
- `offer` · `pause`, `downgrade`, `fix`, `credit` or `none`, and the usage that chose it.
- `note` · The note as sent, with the numbers from their own usage.
- `outcome` · `kept`, `paused` or `lost`, when they answer or the follow-up window ends.

**Connect once:** [Stripe](/code/integrations/stripe), [Gmail](/code/integrations/gmail). **How long:** Minutes to send; waits 5 days for an answer.

## Trigger
It runs on every at-risk flag from Silence watch, and every cancellation.

```ts
on.Customer.atRisk((customer, $) => $.Customer.save({ customer: customer.$id }))
```

## Steps
Your agent does these steps, one row per step; each step’s check is part of its row and its price.

| Step | Verb | Returns | Price | Approval |
| --- | --- | --- | --- | --- |
| 1. Read why they are leaving | `Collection.query` | The cancel reason, 90 days of usage and their recent conversations | Free | Runs on its own |
| 2. Choose the offer under the Policy | `Collection.query` | One offer from the Policy that fits the usage, recorded as a Decision with its reason | About 0.2¢ | Runs on its own |
| 3. Write the note | `Content.draft` | A note with their own numbers and one offer, in two short paragraphs | About 0.4¢ | Runs on its own |
| 4. Send it, and one follow-up to silence | [`Message.schedule`](/code/apps/mailboxes/schedule) | The note sent within 24 hours, one follow-up after 5 days of silence | About 0.1¢ | Runs on its own |

Calls: `Collection.query`, then `Content.draft`, then `Message.schedule`.

About 0.7¢ a customer, each time it runs, capped at 10¢.

## Example: give it a goal
> Try to save the five customers who went quiet, spend up to $1, and show me the plan first.

The plan it shows runs 4 steps, priced before it spends anything; Run it above shows the result it returns.

## When something fails

| When | Then |
| --- | --- |
| The customer has an open support conversation | They are handed to a person with the context, never offered a deal over an open issue. |
| The best fit is a discount past the Policy | The offer is a `price` action: it waits for you, by the seat rule on `price`, and the 24 hours pause while it waits. |
| One customer is flagged twice | One offer per flag: it is idempotent per customer and flag Event. |

Each step retries three times, then falls through to the next route; one item's failure lands in `skipped` with its reason, never failing the call. Before a step would cross `maxCost` it stops and returns what it has, with a `resume` link. Each step's idempotency key is the call's key plus the step and the item: `leaseline-customer-save-1-<step>-<item>`.

## How it is graded
A second model checks each note against the customer’s record and your retention Policy; code times the offer from the flag. It passes when each at-risk or cancelling customer gets a note with an offer inside the Policy within 24 hours, and every fact in it matches their record. A miss is free: you pay nothing for it.

## Change it
| Ask | Compiles to |
| --- | --- |
| Offer a 90-day pause instead of 60. | [`Customer.save`](/capabilities/keep-and-grow/save-and-win-back) |
| Send me anyone paying over $200 a month instead. | [`Customer.save`](/capabilities/keep-and-grow/save-and-win-back) |
| Tell me each week what reasons people give. | [`Metric.query`](/code/apps/analytics-and-experiments/query) |

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

export default workflow({
  name: 'customer-save',
  verb: 'Customer.save',
  input: { customer: 'string', policy: 'string', followUp: 'string' },
  trigger: { event: 'Customer.atRisk' },
  maxCost: 0.1,
  review: { pattern: 'adversarial', rubric: 'every number from their usage; the offer inside the Policy' },
  grade: { evaluator: 'the note against the record and the Policy; time to offer', passWhen: 'an offer in the Policy within 24 hours', remedy: 'free' },
  execute: async (ctx, { customer, policy = 'retention.default', followUp = '5d' }) => {
    const c = await ctx.step('read', () => $.Collection.query({ from: 'customer', id: customer, with: ['usage:90d', 'conversations', 'cancelReason'] }))
    if (c.conversations.some((x) => x.open)) return ctx.step('handoff', () => $.Conversation.answer({ conversation: c.conversations[0].$id, to: 'owner' }))
    const offer = pickWithinPolicy(c, policy)                                            // a choice inside declared bounds
    const note = await ctx.step('write', () => $.Content.draft({ kind: 'save-note', customer: c, offer, maxCost: 0.005 }))
    await ctx.step('send', () => $.Message.schedule({ to: customer, body: note.body, followUp: { after: followUp, ifNoReply: true } }))
    return { offer, note: note.body }
  },
})
```

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

## Works with
**Plays that order it:** [Run it beside the old tool](/plays/run-beside).

It serves [Keep and grow customers](/capabilities/keep-and-grow), and runs in [Keep and grow](/strategy/keep-and-grow). It deepens [`workflow:save-and-win-back`](https://startupbuilder.dev/workflows/processes/retention/save-and-win-back).

## For agents

| Fact | Value |
| --- | --- |
| Guide | `Customer.save` |
| Guide approval | `none` |
| Goal | Try to save the five customers who went quiet, spend up to $1, and show me the plan first. It may change counts, the order of steps, soft filters and the route a read takes, and it stops early when the goal is met. It never relaxes your words, raises its own budget, answers its own approval, calls a Play or acts outside your standing approval. |
| Trigger | `on.Customer.atRisk((customer, $) => $.Customer.save({ customer: customer.$id }))` |
| Retry | Three times with backoff, then the next route; the same idempotency key never charges, sends or posts twice |
| Idempotency key | `leaseline-customer-save-1` |
| Lookups | A `find:` input asks unless exactly one record fits |

| Step | Action class | Seat rule that would hold it |
| --- | --- | --- |
| 1. Read why they are leaving | None | None. |
| 2. Choose the offer under the Policy | None | None. |
| 3. Write the note | None | None. |
| 4. Send it, and one follow-up to silence | None | None. |

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

| Step | Function | Doer | Checker | Assurance | Inputs |
| --- | --- | --- | --- | --- | --- |
| 1. Read why they are leaving | `code` | Code | Code checks each result against its declared type | `measured` | `$0.customer` |
| 2. Choose the offer under the Policy | `agentic` | The [CSM](/capabilities/agents/csm) agent | A second model grades the output | `graded` | `$1, $0.policy` |
| 3. Write the note | `agentic` | The [CSM](/capabilities/agents/csm) agent | Adversarial review by a second model: Pass, or held: every number from their usage, the offer inside the Policy | `graded` | `$1.usage, $2.offer` |
| 4. Send it, and one follow-up to silence | `code` | Code | Code reads the result back from where it landed | `measured` | `$3.passed, $0.followUp` |

The doer is never the checker, and the weakest assurance on the path is the whole call’s.

## Under the hood

| Kind | What it touches |
| --- | --- |
| Systems | [Mailboxes](/code/apps/mailboxes), [sites and content](/code/apps/sites-and-content) |
| Nouns | [`noun:collection`](https://startupbuilder.dev/business/nouns/collection), [`noun:content`](https://startupbuilder.dev/business/nouns/content), [`noun:action`](https://startupbuilder.dev/business/nouns/action), [`noun:message`](https://startupbuilder.dev/business/nouns/message) |
| Events | `Collection.queried`, `Content.drafted`, `Action.evaluated`, `Message.scheduled` |

## Reference

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `customer` | `Customer` | Required | The customer at risk, or the one who cancelled. |
| `policy` | `Policy` | `your retention Policy` | What may be offered without asking: a pause up to 60 days, a downgrade, a fix, or a credit up to the Policy’s amount. |
| `followUp` | `string` | `'5d'` | When a silent customer gets one follow-up. |
| `maxCost` | `number` | `0.10` | The most this call spends each time it runs, in USD. It stops before a step would cross it and returns what it has, with a `resume` link. |

Emits `Customer.saved`. Refuses with [`max-cost`](/code/refusals#max-cost) before it spends, and with the Data calls and Accounts groups ([refusals](/code/refusals)).