Proactive
Tick-driven prompt engine. The host registers prompts ("site tour", "voice-failure hint", "commercial-page dwell"), drives a tick on its own clock, and proactive picks the highest-priority prompt whose triggers all fire — subject to per-session fatigue caps.
Quick start
import { createProactive, triggers } from '@perhapxin/dddk/modules/proactive';
const proactive = createProactive({
analytics, // optional: emits proactive.shown / .response
render: createSubtitleProactiveRender(dddk),
isPaletteOpen: () => paletteOpen,
whenPaletteOpen: 'suppress',
fatigue: {
maxPerSession: 2,
cooldownMs: 30_000,
consecutiveDismissCap: 1,
},
});
await proactive.init();
proactive.register({
id: 'commercial_dwell',
priority: 10,
triggers: [
triggers.pageMatch({ path: '/commercial' }),
triggers.dwell({ ms: 30_000 }),
],
surface: {
text: 'Done browsing? Want to see the integration docs?',
yesLabel: 'See docs',
noLabel: 'Not yet',
},
onResponse: (r) => { if (r === 'yes') goto('/docs'); },
});
// Drive it on your own clock — proactive does NOT auto-tick.
setInterval(() => {
void proactive.tick({
page: location.pathname,
dwellMs: Date.now() - pageEnterAt,
customMeta: { firstVisit: isFirstVisit },
});
}, 5000);
API
createProactive({ analytics, memory, storage, fatigue, keys, identity, render, whenPaletteOpen, isPaletteOpen })await proactive.init()— open storage, load persisted variant statsproactive.register(prompt)/proactive.unregister(id)await proactive.tick(ctx)— evaluate triggers, fire one prompt, returns its idawait proactive.ask({ text, yesLabel, noLabel })— bypass triggers, render a one-off promptproactive.pause()/proactive.resume()proactive.explain(id)— last-shown / variant stats / fatigue counters for a promptawait proactive.dispose()
Registering prompts
A PromptDefinition is just data:
| Field | Purpose |
|---|---|
id |
Unique key. Used for fatigue state + analytics. |
category |
Optional grouping (onboarding, conversion, …). Lets dismissPenalty.sameCategory cool down whole groups. |
priority |
Higher wins when multiple prompts are eligible in the same tick. Default 0. |
triggers |
Array of Trigger. See built-ins below. |
triggerLogic |
'AND' (default) or 'OR'. |
surface |
What to show: text, yesLabel, noLabel, placement, autoTimeoutMs, dismissable. |
onResponse |
(response, ctx) => void — your side effect (navigate, run skill, etc). |
variants |
A/B variants. Each has its own surface. |
variantSelector |
'thompson_sampling' (default), 'epsilon_greedy', or 'random'. |
text may be a string OR a (ctx) => string so you can interpolate
the current page / dwell into the copy.
Built-in triggers
Import as triggers (also re-exported as builtin.triggers):
| Trigger | Fires when |
|---|---|
triggers.pageMatch({ path }) |
ctx.page matches the string (includes) or RegExp |
triggers.dwell({ ms }) |
ctx.dwellMs >= ms |
triggers.idleTime({ ms }) |
Alias of dwell (intent-only) |
triggers.scrollDepth({ percent }) |
ctx.scrollDepth >= percent |
triggers.exitIntent() |
ctx.customMeta.exitIntent is truthy |
triggers.schedule({ everyN }) |
Every Nth tick |
Custom triggers are one-liners — any { id, condition: (ctx) => boolean | Promise<boolean> }:
const firstVisit: Trigger = {
id: 'first_visit',
condition: (ctx) => Boolean(ctx.customMeta?.firstVisit),
};
The tick loop
Proactive is passive — it evaluates only when the host calls
tick(ctx). Your ctx is what fills TriggerContext:
proactive.tick({
page: location.pathname,
dwellMs: Date.now() - pageEnterAt,
scrollDepth: pctScrolled(),
customMeta: { firstVisit, abVariant: 'B' },
});
The engine then, per call:
- Honors fatigue (session cap, cooldown, consecutive-dismiss cap).
- Honors palette state (see below).
- Evaluates every registered prompt's triggers.
- Picks the highest-priority eligible prompt.
- Calls your
render(prompt, surface), awaits a response. - Updates variant stats, persists to storage, fires
onResponse. - Tracks
proactive.shown/proactive.responseifanalyticsis wired.
A 5-second interval is usually fine. Faster ticks don't help — the cooldown gate caps how often anything actually shows.
Fatigue
fatigue: {
maxPerSession: 3, // hard cap on total shows per page session
cooldownMs: 60_000, // minimum gap between any two shows
consecutiveDismissCap: 3, // stop after this many dismisses in a row
dismissPenalty: {
sameId: 'session', // dismissed prompt won't re-fire this session
sameCategory: 300_000, // cool down whole category for 5min
},
}
The dismiss counter resets to 0 on any 'yes' or 'no' answer.
The render contract
render is the only piece you have to wire to UI — proactive doesn't
ship a default surface. Signature:
type RenderFn = (
prompt: PromptDefinition,
surface: PromptSurface,
) => Promise<'yes' | 'no' | 'dismiss'>;
Resolve 'yes' / 'no' for an explicit user choice, 'dismiss' for
"user ignored / closed / timed out" (which counts toward the
consecutive-dismiss cap).
The dddk-frontend reference renders into the subtitle bar so prompts share the same center-bottom surface as voice / agent / selection output:
import type { DotDotDuck } from '@perhapxin/dddk';
import type { PromptDefinition, PromptSurface } from '@perhapxin/dddk/modules/proactive';
export function createSubtitleProactiveRender(dddk: DotDotDuck) {
return (_prompt: PromptDefinition, surface: PromptSurface) =>
new Promise<'yes' | 'no' | 'dismiss'>((resolve) => {
const text = typeof surface.text === 'function'
? surface.text({ now: Date.now() })
: surface.text;
dddk.subtitle.show({
text: `${text} · ${surface.yesLabel ?? 'Yes'} / ${surface.noLabel ?? 'No'}`,
type: 'agent',
onAccept: () => { dddk.subtitle.hide(); resolve('yes'); },
onReject: () => { dddk.subtitle.hide(); resolve('no'); },
onCancel: () => { dddk.subtitle.hide(); resolve('dismiss'); },
autoHide: surface.autoTimeoutMs,
});
});
}
For a generic adapter that targets dddk's PieceSurface system, use
the bundled createPieceRender({ mount, locale }) helper.
Palette coordination
If the user already has the palette open, you usually don't want a
proactive prompt fighting for attention. Wire isPaletteOpen and
whenPaletteOpen:
| Mode | Behavior |
|---|---|
'suppress' (default) |
Skip the tick entirely while palette is open |
'subtitle_only' |
Only fire prompts that the renderer can route to subtitle bar; non-Space input is treated as "didn't see it" |
'blur_palette' |
Render anyway; the host renderer is responsible for blurring the palette behind |
dddk exposes isPaletteOpen() and (via PanelSkill) isPanelOpen()
— combine them so a Panel doesn't get interrupted either:
isPaletteOpen: () => paletteOpen || dddk.isPanelOpen?.() === true,
A/B variants
Provide variants: [{ id, surface }, ...] and the engine picks one
per fire using Thompson sampling over per-variant yes/no stats
(stored in IndexedDB across reloads). Use 'epsilon_greedy' for a
cheaper bandit, 'random' for uniform sampling, or omit variants
to disable.
proactive.explain(id) returns the live variant stats — useful for
a debug panel.
Through the webagent
Proactive is NOT exposed as an LLM tool. Prompts are designed to
fire from host-known signals (page, dwell, custom flags) — the LLM
doesn't need a "show prompt" tool, it can just dddk.subtitle.show()
directly when it has something to say.
See analytics for the events proactive emits.