@intunix/core
v1.2.1
Published
Renderer-agnostic core of the official Intunix SDK — the AI-native experience management platform for in-app surveys, NPS, CSAT and feedback forms.
Maintainers
Readme
@intunix/core
Renderer-agnostic core of the official SDK for Intunix, the AI-native experience management platform — the logic layer behind in-app surveys, NPS, CSAT, and feedback forms.
It contains the IntunixClient (event queue, trigger evaluator, experience
manager, offline persistence, session tracking) and the framework-agnostic
FeedbackRunner + types. You normally consume it through a binding rather than
directly:
- @intunix/web — drop-in
cx.jsfor any site (loader tag). - @intunix/react — React provider + hooks.
- @intunix/vue — Vue 3 provider + composables.
Install
npm install @intunix/coreDirect use
import { IntunixClient } from '@intunix/core';
const client = new IntunixClient({ renderer: myRenderer });
// Register makes a form auto-show when its trigger matches. Do it at init…
await client.init('pk_live_xxx', { register: ['nps_q4'] });
// …or anytime later (returns an unregister fn):
const off = await client.register('exit_survey');
client.identify('u_42', { plan: 'pro' });
client.track('checkout_completed', { value: 99 });
client.showForm('nps_q4'); // or just open one manually, no register needed
client.reset(); // on logout — see "Identity, and what reset() is for"A Renderer decides how experiences are displayed — see the React/Vue/web
bindings for reference implementations.
How forms show
- Opt-in: a form auto-triggers only after you
registerit (by slug or id). Unregistered forms never pop on their own — butshowForm(slug)still opens any form manually.registeralso controls what gets fetched at all, and is only half the story — see Register modes and the console gate below. - One at a time: trigger matches and manual
showFormcalls funnel through one queue, so forms appear in sequence, never stacked. - URL delay: URL-triggered forms wait
urlTriggerDelayMs(default 600) so they don't pop the instant a page loads.
await client.init('pk_live_xxx', { register: ['welcome', 'nps_q4'], urlTriggerDelayMs: 600 });Register modes and the console gate
register is typed string[] | 'all', and it drives the fetch itself,
not just which triggers go live:
| Config | Fetched | Behavior |
|---|---|---|
| register: 'all' | every SDK-enabled form | autoActivate forms self-show |
| register: ['nps_course', 'csat'] | only those | their triggers go live |
| omitted / [] | nothing — no catalog request is made at all | only showForm(id) pulls that one form, on demand, via the single-form endpoint |
A form is delivered to the SDK only when both independent gates agree:
sdk_enabled— "Available in Web SDK" is toggled on for the form in the Intunix console.register— the host'sregisterlist includes the form's slug/id, orregisteris'all'.
The backend enforces gate 1 unconditionally — a form the console hasn't
enabled for the Web SDK is excluded from the response regardless of
register. Gate 2 is enforced by what the host actually asks for. This
pairing is the #1 source of "I registered my form and nothing loaded" —
verify both sides, not just the register call, before assuming a bug.
Implementation details that follow from this:
- The requested form set is sorted before being sent
(
ExperienceManager.formsParam), soregister: ['b', 'a']andregister: ['a', 'b']produce the identical?forms=a,brequest and share a cache entry. - The catalog cache (
ExperienceCache) carries two fingerprints: the session it was fetched for, and the requested form set. A cache hit requires both to match the live session and the liveregister— a changedregisterlist invalidates the cache immediately, even mid-session, independent of the session-start fetch policy below. - The backend's ETag is
W/"<version>-<hash of requested set>", so two hosts (or the same host across a config change) requesting differentregisterlists never share a 304 with each other.
Context
Attach dynamic context — course id, screen, plan tier, anything — to targeting, responses, and cooldown scoping, without a redeploy.
Sticky context merges into a session-scoped bag and stays attached to every show/response until cleared or a new session starts:
client.setContext({ courseId: 'c-101' }); client.setContext(null); // or client.clearContext()Per-call context is scoped to a single
showForm/showExperienceand wins over sticky context for that call:client.showForm('nps_course', { context: { courseId: 'c-102' } });
Context flows into every response payload as a top-level context object (so
the console can segment feedback per course/screen), into event batch entries,
and into targeting.rules entries with kind: 'context'. Use frequencyScope:
'form_context' (below) to keep cooldowns independent per context value.
Identity, and what reset() is for
The SDK needs an identity to key cadence records against — "shown once per
user", "quiet for 30 days after they answer". Most visitors are anonymous, so
it mints a random anonymous id on first visit (localStorage,
intunix:anon) and uses it whenever no user is identified:
const uid = user?.id ?? anonId();identify(userId) moves everything recorded under the anonymous id onto the
real one. So the anonymous id only ever holds activity since the last
identify() — a form dismissed while logged out still counts after logging in.
client.identify('u_42'); // anon records move to u_42; the anon key is now empty
client.reset(); // on logoutWhat reset() actually changes
Call it on logout. It forgets the identified user, rotates the anonymous id, ends the session, and clears sticky context.
It is not what keeps one user's cooldowns off another user. identify()
already does that, because it drains the anonymous key on every login — a
returning user's caps live under their own id, not the shared anonymous one. If
you call identify() on every login, gating stays correct even without
reset().
What you lose by skipping it, on a shared browser:
| Without reset() | Consequence |
|---|---|
| Session is not ended | The next person lands inside the previous session (30-min TTL), so every_session will not re-fire for them. |
| Sticky context survives | intunix:context is sessionStorage-scoped, so the previous user's context attaches to the next person's responses until the tab closes. |
| Anonymous id is not rotated | Both people's logged-out responses carry the same anonymous_id, so reporting counts them as one respondent — and it stays that way in the database, permanently. |
That last one is the reason to bother: the others self-heal, but a response is written once and keeps whatever ids it had at submit time.
If your logout is a single-page transition with no page reload, reset()
matters more: the identified user is held in memory, so without it the SDK keeps
attributing responses to the previous user until the next identify() lands.
What it deliberately keeps
The form catalog, and the per-device firstSeenAt / sessionCount that
eligibility.minSessions and minDaysSinceFirstSeen gate on. Those are
device-level, not user-level — clearing them would let anyone reset themselves
to "brand-new user" by logging out.
Console-driven catalog fields
Everything below is set per-form in the Intunix console — no SDK update or site redeploy required. Fields are optional; a form or an older catalog without them behaves exactly as it does today.
| Field | Meaning | Example |
|---|---|---|
| autoActivate | Form auto-triggers on catalog load — no register() call needed in host code. | autoActivate: true |
| targeting.traitEquals | Legacy: match identified-user traits by equality (ANDed with rules). | { plan: 'pro' } |
| targeting.rules | { kind: 'trait' \| 'context' \| 'url', key?, op, value? }[], ANDed. op is one of eq \| neq \| in \| nin \| gt \| lt \| contains \| exists. | { kind: 'context', key: 'courseId', op: 'eq', value: 'c-101' } |
| eligibility.minDaysSinceFirstSeen | Skip users first seen fewer than N days ago. | 3 |
| eligibility.minSessions | Skip users with fewer than N sessions. | 2 |
| eligibility.startAt / endAt | ISO date-time scheduling window the form is eligible within. | "2026-08-01T00:00:00Z" |
| cadence.mode | How often the form may show: once | every_session | cooldown | always. | 'cooldown' |
| cadence.cooldown.answered | After the user answers, wait this long before showing again. Days, 'session', or 'never'. | 30 |
| cadence.cooldown.dismissed | After the user closes it without answering, wait this long. | 7 |
| cadence.cooldown.ignored | After the user ignores it (no interaction), wait this long. | 1 |
| cadence.giveUpAfterShows | Stop forever after this many shows with no answer. Ignored when mode: 'always'. | 3 |
| frequencyScope | Cooldown key: 'form' (default — one cooldown per user) or 'form_context' (cooldown scoped per context value named by the form's kind: 'context' targeting rules). | 'form_context' |
| capabilities.batchShown | Catalog-level (not per-form): impressions/dismissals ride in the /sdk/events batch as $form_shown/$form_dismissed instead of a per-impression POST. Controlled server-side. | true |
Evaluation order at show time: targeting → eligibility → cadence. All must
pass.
Every cadence wait measures from the last time the form was shown; the outcome
only selects which wait applies. every_session is shorthand for a 'session'
wait on every outcome, and once for a 'never' wait — so there is one rule to
reason about, not two that interact.
Request budget
What actually goes over the wire — the part third-party integrators care about:
- The catalog (
GET /sdk/experiences?forms=…) is fetched once per session, for the currently registered form set, at session start. A page load, reload, or SPA navigation within a live session, with the sameregister, makes zero catalog requests — the localStorage cache is authoritative and is stamped with both the session and the form set it was fetched for. - Changing
register(including switching to/from'all') invalidates the cache immediately, even mid-session, and triggers an immediate refetch. - Passing no
register(or[]) skips the catalog fetch entirely — theexperienceslist stays empty andshowForm(id)resolves that one form on demand viaGET /sdk/forms/:idinstead. - The first fetch for a given session+set sends
If-None-Matchand is normally a304 Not Modified(no body). - There is no
POST /sdk/init— SDK name/version rides on anX-Intunix-Sdkheader on the catalog GET instead. - Impressions and dismissals ride inside the periodic
/sdk/eventsbatch as$form_shown/$form_dismissed(when the catalog'scapabilities.batchShownis on), not as a per-impression POST. - Events flush on a timer (
flushIntervalMs, default 5000 ms) and vianavigator.sendBeacononpagehide, so a pending batch isn't lost on tab close.
License
Proprietary © Intunix.
