Moments — when Sarah shows up
A Moment is a user state, not an event hook — the eleven states she recognises, what each costs you to enable, and how she avoids asking the same person twice.
At any second, every user of your product is in some state. Just landed. Two diagrams in and stuck. Reading your pricing page. Finger on the confirm-cancel button.
A Moment is one of those states. It is not a trigger and not an event hook — those are the plumbing underneath it. A Moment is the wrapper around the plumbing: the user is in state X right now, and here is the one question only askable in state X.
That distinction is the entire mechanic. Sarah arrives while the context is still loaded in the user's working memory, so instead of "why did you leave?" she can ask "walk me through the week before you decided" — and they actually answer, because they're still in it.
Twenty-four hours later that context is gone and you get the tidy version: "too expensive." "just exploring." "7 out of 10." A Diffus user ticked "too expensive" on the exit survey. Asked inside the product a minute earlier, the same person said:
"The price is fine — I was happy to pay as long as it delivered. It didn't."
Then named the competitor they were switching to. Same person, same decision, two different answers. The difference isn't the question. It's the second you asked it in.
The eleven built-ins
Eleven Moments ship with the product — one per state Sarah can recognise today.
A Moment is a point in time, not a category. It says when she engages, and nothing about which of your goals that conversation ends up serving. The grouping below is only a hint about where each one usually lands. A cancel chat routinely surfaces the pricing objection that belongs to Grow; a pricing_visit often turns into the clearest bug report you'll get all week. Read the left column as "where this tends to matter", never as ownership.
The last column is the setup tier from How much you have to build, and it means exactly what it means there. All four tiers assume the script tag is in and identify() is being called.
| Usually lands in | Moment | ID | What state it catches | What it costs you |
|---|---|---|---|---|
| Activate | Welcome | welcome | First fifteen seconds — they're here and haven't done anything yet | Free |
| First value | first_use | Their first real success just completed — not their first attempt | One line of code | |
| Generation failed | generation_failed | They tried and the output missed | One line of code | |
| Stuck | stuck | Everything they need is on screen and they still don't know what to do | One line of code | |
| Keep | Cancel | cancel | Right before the final confirm step of a cancellation | One line of code |
| Return visit | return_visit | A non-paying user is back — 2nd session, or after ≥ 7 days quiet | Free | |
| Grow | Out of credits | credits_exhausted | Interrupted mid-task by their own usage limit | Free |
| Visiting pricing | pricing_visit | Evaluating — on /pricing for ≥ 10s | Free | |
| Paywall exit | paywall_shown | They saw the price and backed out, and didn't go to /pricing in the 5 minutes after | One line of code | |
| Return paid user | return_paid_user | A paying user is back — 2nd session, or after ≥ 7 days quiet | A field in identify() | |
| Anywhere | Open feedback | open_feedback | Whatever they chose to bring you — the one Moment where the user picks the topic | A button you add |
Why two stages aren't in that table
Understand isn't in the table for the opposite reason. It doesn't need a Moment because it runs on all of them. Every completed conversation is mined for signals, personas, and demand afterwards, whichever Moment opened it. A cancel chat, a pricing chat, and a stuck-user rescue all feed the same Signals board. That's why turning on more Moments makes Understand better without you configuring anything.
Two more notes on the table.
credits_exhausted is free because she watches for HTTP 402 responses coming back from your own API. If your product already returns 402 when a user runs out, it fires with no instrumentation at all. Two things have to be true for that: your API returns 402, and 402 detection is on — which it is on every new project, unless you switch it off in the dashboard. Miss either one and the Moment simply drops a tier to UserSay.trigger('credits_exhausted'), behaving identically from there.
Understand has one Moment but feeds on all eleven. Signals and personas are built from every transcript, whatever fired it. open_feedback is listed there because it's the only one where the user, not your product, decides what the conversation is about.
Wiring the ones that need a line
The five one-line Moments are the ones only you can define — she won't guess at what a first success looks like in your product, because a Moment that fires at the wrong second costs more than one that doesn't fire. Each is a single call, placed where the thing actually happens:
UserSay.trigger('first_use'); // in the success callback of their first creation
UserSay.trigger('generation_failed'); // repeated regenerations, a low rating, a big manual edit
UserSay.trigger('stuck'); // your own idle / retry / dead-end heuristic
UserSay.trigger('paywall_shown'); // when the user closes your upgrade modal
UserSay.trigger('cancel'); // in your cancel flow, alongside the confirmTwo behaviours are worth knowing before you place them. paywall_shown doesn't fire on the call — the SDK waits five minutes and fires only if the user didn't head to /pricing in the meantime, because someone who went to read the pricing page hasn't bounced. And cancel is the one worth wiring twice: trigger() puts Sarah alongside a cancellation your own code still performs, which is all you can do until she has tools. Once she has them, hand her the button instead — a different call, and the difference between a cancel understood and a cancel saved. Either way the cancellation goes through; what must never happen is your flow waiting on her, because the SDK reports a completed interview and not an abandoned one.
The button Moment uses a different call:
feedbackBtn.addEventListener('click', () => {
UserSay.openPrompt('open_feedback');
});openPrompt() rather than trigger(), because it skips the dedup checks entirely. The user asked for the chat; there is nothing to suppress.
Choosing which to turn on
Seven Moments are enabled the day you create a project: welcome · return_visit · pricing_visit · credits_exhausted · paywall_shown · cancel · open_feedback. Four of those need no code, so a fresh install starts talking to users on its own; the other three sit enabled but silent until you wire them.
Turning on all eleven at once is the mistake worth avoiding. Too many conversations, too thin a signal in each, and your first week on the Signals board reads as noise. Start with three:
welcome— free, fires on a first visit, and tells you who is actually showing up. It often doesn't match who you think the product is for.open_feedback— point your existing Feedback button at it. Lower volume than the rest, higher intent than any of them: they came to you.- One high-intent Moment, whichever surface your product actually has —
cancelif there's a cancel flow,credits_exhaustedif there are usage limits,paywall_shownif there's an upgrade modal.
Three Moments produce roughly 20–50 conversations in a first week. Enough to see one pattern; not so many that everything blurs.
Add the fourth reactively. When the Signals board shows a gap you wish you could hear into — "a lot of people bounce off the paywall and I have no idea why" — that's the prompt to enable paywall_shown. Adding a Moment in answer to a question Sarah already raised beats adding it speculatively.
Suppression and dedup
She won't pester the same person twice. Every Moment carries a dedup policy, tracked separately for three different endings:
- Fired, no engagement — the bubble appeared and the user ignored it or closed the tab.
- Completed — the chat ran and ended normally.
- Dismissed — the user actively closed the bubble or said no thanks.
Each ending gets its own gate: never again, a cooldown of N days, once per browser session, or no gate at all. The shipped defaults:
| Moment | After an unengaged fire | After a completed chat | After a dismissal |
|---|---|---|---|
welcome · first_use · cancel | never again | never again | never again |
return_visit · return_paid_user · paywall_shown | 7 days | 30 days | 7 days |
credits_exhausted · generation_failed | 1 day | 30 days | 7 days |
stuck | once per browser session | 30 days | 7 days |
pricing_visit | never again | never again | 30 days |
open_feedback | no gate | no gate | no gate |
Three things are worth reading off it. The never-again Moments are the ones that genuinely happen once — there's one first visit and one cancellation per user, so a second ask would be a fiction. stuck is gated per browser session because that state recurs within a session and the bubbles would otherwise pile up across reloads. And open_feedback has no dedup at all: the user clicked your button, so there's nothing to suppress — a rate limit is the only thing behind it.
The counters live server-side against the uid you pass to identify(), and the SDK waits for them before its first suppression check. A user who ignored welcome on their laptop doesn't meet it again on their phone. Session-only gates are the exception, being per browser session by definition.
Any of the three endings can be overridden per Moment in the dashboard.
Customising one
Every Moment ships with defaults, and each is editable per project.
The opener. Her first line, supporting {product} and {reward}, with a held translation per locale if you want one. Most defaults are plain curiosity — welcome opens with "hey! what do you do? curious how you found {product} 😊". Only credits_exhausted names the reward up front, because that user is blocked mid-task and the incentive is what starts the conversation at all.
What she tries to learn. Each Moment carries a list of learnings — human-language lines, not internal keys, that go straight into her prompt. The recommended ones are pre-checked; suggested ones sit unchecked until you want them (cancel suggests "Whether they might come back later"). A separate open-exploration toggle decides whether she may follow a thread past the list. It's on by default, and it's where the surprising material tends to come from.
Depth. Two settings, and neither is a turn budget — conversation length is signal-based, and an engaged user keeps going regardless. Depth only moves the closing threshold. quick closes once she has the headline: who they are and what they came to do. deep holds out for at least one differentiator — a prior tool, a blocker, a price anchor, a recommendation. Eight of the eleven ship deep; return_visit, return_paid_user and open_feedback ship quick.
Two smaller knobs. Reward — credits, a coupon, or a link. open_feedback can never carry credits (the server strips it; they came to you unpaid and paying for that is the wrong shape), and pricing_visit ships with no default reward on purpose, because it's enabled out of the box and a hardcoded coupon code would be a promise Sarah makes that your checkout can't keep. Open mode — a preview bubble first, or the chat straight away. open_feedback defaults to chat, since the user already clicked something and a second preview is redundant.
Next: Plays — what she already knows to do once one of these puts her in the room.
Meet Sarah
The person on the other side of every UserSay conversation — why she's built as a character rather than a rulebook, and what that changes about what users tell her.
Plays — what you don't have to teach her
The operating experience Sarah arrives with. Four situations where a conversation is worth real money, what someone who has run them before actually does, and why the reflex answer is usually the expensive one.