Recipes
Five intents, each a config, the events to emit, and the reveal. The ad placement is one of these, not the point of the library. All durations are milliseconds.
const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;Onboarding tip when the user is stuck
Short half-life: being stuck is a state of the current session. A completed task is strong evidence against.
stuck: {
prior: 0.1,
priorStrength: 5,
halfLife: 10 * MINUTE,
signals: { 'editor.undo': 1, 'search.empty': 2, 'help.opened': 3, 'task.completed': -4 },
outcome: 'help.article.read',
saturate: false, // the fifth undo in a row is more evidence, not less
reveal: { threshold: 0.6, cooldown: HOUR },
}saturate: false because here the repetition is the evidence: by default the n-th repeat of a signal within a session adds less than the first (see Signals & weights), which suits "viewed pricing ten times" but not "undid ten times". Emit editor.undo, search.empty, help.opened, task.completed from the UI; help.article.read when a help article is actually read. When shouldReveal(state, 'stuck', now) is true, show a contextual tip and call markRevealed. With priorStrength: 5, two empty searches and an undo (weight 5) already clear p = 0.5; the lower bound needs a little more.
Upgrade offer
The quickstart. Signals that are followed by upgrade.completed grow heavier for this user, the ones that aren't shrink, and the outcome resets the slate and pauses offers for a week.
upgrade: {
prior: 0.05,
priorStrength: 10,
halfLife: 7 * DAY,
signals: { 'pricing.viewed': 3, 'limit.hit': 2, 'export.clicked': 1, 'plan.downgraded': -4 },
outcome: 'upgrade.completed',
}Churn save
Slow-building, costly to act on: a longer half-life, a stronger prior, and a stricter bound before offering a discount.
churn: {
prior: 0.08,
priorStrength: 20,
halfLife: 14 * DAY,
signals: {
'session.short': 1,
'notifications.disabled': 3,
'export.all': 4, // taking their data with them
'feature.new.used': -2,
},
outcome: 'account.cancelled',
reveal: { threshold: 0.5, z: 1.64, cooldown: 30 * DAY },
}Emit session.short from your own session-length logic at endEpisode time. When the gate opens, reach out or offer a save; the outcome you hope never arrives still teaches the engine which signals mattered when it does.
Feature discovery
Reveal advanced features to people who are ready for them. The outcome is the user adopting the feature on their own.
powerUser: {
prior: 0.1,
priorStrength: 10,
halfLife: 3 * DAY,
signals: {
'command.palette.opened': 2,
'shortcut.used': 2,
'bulk.select': 3,
'settings.advanced.opened': 2,
'tour.dismissed': -1,
},
outcome: 'shortcuts.customized',
reveal: { threshold: 0.6, cooldown: 14 * DAY },
}When it fires, surface the keyboard-shortcut cheat sheet or the advanced panel once, and let markRevealed keep it quiet for two weeks.
Ad placement
Show a sponsored placement to users who are likely to buy, and only then. A strong prior and a low base probability keep it quiet for browsers.
willBuy: {
prior: 0.02,
priorStrength: 50,
halfLife: 3 * DAY,
signals: { 'product.viewed': 1, 'cart.added': 3, 'wishlist.added': 2, 'cart.emptied': -3 },
outcome: 'checkout.completed',
reveal: { threshold: 0.4, z: 1.28, cooldown: DAY },
}shouldReveal(state, 'willBuy', now) gates the placement; markRevealed after showing it. Because nothing leaves the device, the targeting decision is made entirely in the app, with no profile anywhere else.
Putting several together
One state holds all intents. Emit events once; each intent takes what it declares. The same cart.added can be evidence for willBuy and against churn.
const config = { episodeTimeout: 30 * MINUTE, intents: { stuck, upgrade, churn, powerUser, willBuy } };See Using with frameworks for the load → observe → persist lifecycle around it.