@sentientui/core
v0.36.0
Published
SentientUI core SDK — the framework-agnostic client that powers adaptive UI personalization: session tracking, bandit variant assignment, adaptive slots, and persona decisions. SSR-safe.
Downloads
4,010
Maintainers
Readme
@sentientui/core
Framework-agnostic JavaScript SDK for SentientUI — a Thompson Sampling bandit + persona/portrait engine that automatically surfaces the best-performing variant for each visitor. Learning runs on the SentientUI hosted API.
Most users should install
@sentientui/reactinstead — it bundles this package and adds the SSR-safe<AdaptiveRoot>,<Adaptive>, and hooks. Use@sentientui/coredirectly only if you are not building with React.
Installation
npm install @sentientui/coreQuick start
import { init } from '@sentientui/core';
const client = init({
apiKey: 'pk_your_key', // from sentient-ui.com → Settings
});
// Get a variant assignment for a component (returns null during SSR)
const result = await client.assign('hero_headline', ['control', 'variant_b']);
console.log(result?.variantId); // e.g. 'variant_b'
console.log(result?.content); // managed text content if the variant is WYSIWYG-managed
// Credit the variant served for a component when the visitor converts
// (feeds the per-variant CVR funnel — no variantId plumbing needed)
client.componentGoal('hero_headline', 'trial_started');
// Or record a session-level funnel goal not tied to any component
client.goal('trial_started', { plan: 'pro' });init() returns a no-op client during SSR (typeof window === 'undefined'). With consent: false it returns a consent-gated client that stores and sends nothing. client.gated is true until the client is upgraded (it is false under DNT/GPC, which consent can't override); client.released is true once a gated client was disposed or destroyed, after which it can't be upgraded (it still reads gated: true, so check released). Goals fired on a released client are dropped (logged with debug: true). goal() calls are held in memory and sent if grantConsent() upgrades it in this page view. decide() resolves when consent is granted — with the decision, or null if the page changed in the meantime — or null at once under DNT/GPC or after dispose()/destroy(). destroy() on it deletes anything an earlier consented visit stored, and after it the client holds nothing more. A gated client that was disposed or destroyed can't be upgraded any more: to start tracking after that (a visitor who refused, then accepted), call init() again, then grantConsent().
Reading a consent platform yourself: import { consentWatcher } from '@sentientui/core/consent' — the same presets as the React and snippet consentFrom ('cookiebot', 'onetrust', 'cookieyes', 'tcf', 'google-consent-mode' with an optional region, 'shopify', or { cookie } / { check, refused? }), with read(), refused() and subscribe(). Pass nonce to init (or call setCspNonce) for a nonce-based CSP. When apiKey does not start with pk_, it returns a no-op client in production builds, or the keyless local-mode client in development builds (see "Keyless local mode" below). The hosted ingest URL (https://api.sentient-ui.com/v1/events) is built in — no URL configuration required.
API
init(config) → SentientClient
| Option | Type | Description |
|--------|------|-------------|
| apiKey | string | Public API key (pk_…) from the SentientUI dashboard. |
| context | 'landing' \| 'ecommerce' \| 'saas' \| 'marketplace' (optional, deprecated) | Unused — the project's type is set in the dashboard. Safe to omit. |
| consent | boolean (default true) | When false, returns a consent-gated client (no cookies, no events; see above). The default is true — for GDPR-style opt-in, pass false until your banner is accepted (see preConsentBehavior). |
| preConsentBehavior | 'control' \| 'statistical_winner' | What to render while consent is false: 'control' (the default — shows variantIds[0]), or the read-only statistical winner via /v1/winner (no session, no events). |
| respectDoNotTrack | boolean (default true) | Honors the browser DNT signal — overrides consent: true and blocks grantConsent(). |
| initialAssignments | Record<string, string> | SSR-preloaded assignments. Seeds the cache so assign() returns without a network call for listed code variants. (Managed-text components still fetch once when the seed carries no content.) |
| sessionSegment | string | Segment from SSR (device:source). Must match the value used in preloadAssignments. |
| ssrSessionId | string | Session ID minted during SSR (from readSessionCookie) so server and client share one session. |
| userId | string | Optional cross-session identity. Persists portraits across sessions for the same user. |
| persona | string | Declared persona — the role your app already knows for this visitor (e.g. 'admin', 'evaluator'). Must be a key in the project's persona vocabulary (dashboard → Settings → Personas); unrecognized values are ignored server-side and surfaced in the dashboard so you can add them. Served at full confidence, overriding the inferred persona. Keep it a low-cardinality role label — never a user id or email. |
| country | string | ISO 3166-1 alpha-2 country code, if you already know it server-side. |
| debug | boolean | Logs events to the console and exposes window.__sentient. |
| localMode | 'auto' \| boolean | Keyless local engine. 'auto' (default) enables it only under the development export condition; production builds without a key short-circuit to defaults with one console.error. |
| initialSlots | Record<string, string \| Record<string, string>> | SSR-preloaded slot results (from preloadDecisions/loadAdaptiveDecision). |
| initialPersona | { persona: string; confidence: number } | SSR-preloaded persona, so client and server agree on first paint. |
client.assign(componentId, variantIds?) → Promise<AssignResult | null>
Asks the hosted bandit for a variant. Cached locally per (componentId, segment) — repeat calls hit the cache. Returns null during SSR or when the session has no ID.
type AssignResult = {
variantId: string;
assignmentTtlMs: number;
content?: string; // populated when the variant is dashboard-managed (WYSIWYG)
};client.track(event)
Queues an event for batched ingest. Events flush every 5 s and on visibilitychange / page unload (via fetch with keepalive: true).
client.goal(name, options?)
Fires a named goal for the current session. Used for cross-component conversions (e.g. 'trial_started', 'purchase_completed') where you cannot scope the reward to a single <Adaptive>.
client.goal('purchase_completed', { value: 49.9, currency: 'EUR', externalId: order.id });value— revenue of this conversion, in the project currency;currency(ISO 4217) only when it differs.externalId— your order/transaction id: dedupes retries and makes later refunds possible.metadata— extra fields stored with the goal.weight(0–1, default1.0) — partial reward value for funnel steps before the final conversion. Step weights are summed (capped at 1.0 per session) and credited when the visit is finalized, about 30 minutes after the visitor goes inactive — not instantly.stepIndex(default0) — position in the funnel for analytics grouping.
The positional form goal(name, metadata, weight, stepIndex) still works and is deprecated.
Which goal method?
goal()is session-level — it POSTs to/v1/goalswith no component/variant, so it appears in funnel charts but not the per-variant CVR breakdown. For variant experiments, prefercomponentGoal()(below) or the declarative<Adaptive goal={…}>prop, both of which attribute the conversion to the served variant.
client.componentGoal(componentId, goalType, opts?)
Records a conversion attributed to the variant currently served for componentId, so it feeds the per-variant CVR funnel. Resolves the served variant from the local assignment cache — you don't pass variantId or projectId. Emits a goal_achieved event (the same signal <Adaptive goal> fires automatically).
client.componentGoal('hero_headline', 'hero_contact', {
reward: 1, // 0–1, default 1
metadata: { method: 'whatsapp' } // merged into the event payload
});No-ops (with a debug warning) if the component has not been assigned yet — render its <Adaptive> / call assign() first. Prefer this over a hand-rolled client.track({ eventType: 'goal_achieved', … }), which requires you to thread the variantId through yourself. In React, use the useAdaptiveGoal hook.
client.identify(userId)
Attaches a stable user ID to the session. On the link, the server copies the highest-reliability portrait from the user's other sessions onto this one, so portraits and cluster assignment carry forward across devices for the same userId.
client.getAssignment(componentId, segment)
Synchronously returns the cached assignment, or null if not yet assigned. Use when you need a non-async lookup.
client.getGraph()
Returns the current GraphSnapshot (page nodes captured by the optional graph scanner — see "Optional: graph mode" below). Permanently returns an empty snapshot on the lean client; graph mode must be enabled at init time.
client.dispose()
Routine cleanup: stops the flush timer and unload listeners (with a final flush) but keeps the visitor identity, decision snapshot, and retry bucket. Use this when a component or provider that owns the client unmounts or re-initializes — the visitor must survive it. <AdaptiveProvider> calls it for you on cleanup.
client.destroy()
Everything dispose() does, plus deletion of the visitor identity — the 365-day _snt_uid_<key> cookie, local/session storage keys, the decision snapshot, the assignment cache and the persisted retry buckets. A decide or assign still in flight never writes any of it back. This is a consent-revocation/forget-me teardown, not a page-unload cleanup: calling it on every unload makes each visit a brand-new visitor and defeats return-visit adaptation. For unload, do nothing — the SDK already flushes on visibilitychange/pagehide automatically.
Public API
What applications may rely on across minor versions:
init(config)→SentientClient, and everySentientClientmethod documented abovegrantConsent(apiKey?)— upgrade a client created withconsent: falseisDoNotTrackEnabled()— whether DNT/GPC gates tracking in this browserderiveSessionSegment({ userAgent, referer, appOrigin })— thedevice:sourcesegment keysetCspNonce(nonce)— CSP nonce for injected<style>(or passnoncetoinit)renderPrePaintScript(apiKey)— the inline pre-paint script (see "Decision snapshot"); render it only once consent is knownforgetVisitor(apiKey)— delete everything the SDK stores for this visitor, with or without a live client (a refusal recorded before any grant;client.destroy()does the same for a client)- the subpath entries
/server,/graph,/consent, and every exported type
Everything else the root entry exports (block vocabularies, snapshot I/O, agent-UA tables,
reveal, toWireSlot, …) is plumbing shared with @sentientui/react and
@sentientui/snippet. It carries no semver promise and leaves the root entry at 1.0.
src/public-surface.test.ts classifies every export, so the list can't grow silently.
SSR helpers
import { preloadAssignments, readSessionCookie } from '@sentientui/core/server';
// In your server loader / getServerSideProps / Server Component.
// `cookies` must expose `get(name)` — Next.js `req.cookies`, `headers().cookies()`, or any
// object with the same shape.
const sessionId = readSessionCookie(cookies) ?? crypto.randomUUID();
const initialAssignments = await preloadAssignments(
[
{ id: 'hero_headline', variantIds: ['control', 'variant_b'] },
{ id: 'pricing_cta', variantIds: ['monthly', 'annual_first'] },
],
sessionId,
{
apiKey: process.env.NEXT_PUBLIC_SENTIENT_API_KEY!,
baseUrl: 'https://api.sentient-ui.com/v1',
origin: process.env.APP_ORIGIN, // must be in the project's allowed origins
userAgent, // from request headers, aligns segment with the client
referer,
serverKey: process.env.SENTIENT_SECRET_KEY, // optional, server-only — see below
},
);serverKey (optional) is the project's secret key, read from a server-only env var. With it, SSR calls carry X-Sentient-Server-Key and the API holds them to your plan's per-key limit; without it, every SSR request from one server address shares the 100 requests/min per-IP cap meant for browsers. It is never sent when window exists, but keep it out of NEXT_PUBLIC_* / client bundles regardless.
Pass initialAssignments and the same sessionSegment to init() on the client to prevent hydration mismatches.
For pages with a section layout, use preloadDecisions instead — same options, plus a sections: string[] request field. The return value carries both assignments and layoutOrder.
decide() — slots, layout, and persona in one call
const outcome = await client.decide({
sections: ['hero', 'pricing', 'faq'], // optional page-order request
slots: [
{ id: 'hero', dims: { tone: ['calm', 'urgent'] } }, // token slot (first value = baseline)
{ id: 'pricing-area', arms: ['standard', 'social_first'] } // enumerated slot
],
});
// outcome: {
// layoutOrder: string[] | null,
// assignments: Record<string, string>,
// slots: { hero: { tone: 'urgent' }, 'pricing-area': 'social_first' },
// persona: 'admin', confidence: 1, // declared personas serve at full confidence
// }
client.getSlotResult('hero'); // sync read of a decided slot
client.getPersona(); // { persona, confidence, band: 'low' | 'medium' | 'high' }Decisions are locked per session. Apply dims results as data-<dim> attributes and style them
with CSS. At least one of sections / components / slots must be present.
client.requestSlots(slotIds, baselineTexts?) / client.onSlotsChanged(listener)
Optional client methods behind @sentientui/react's generated-version <Adaptive> — regions
whose versions are written in the dashboard rather than in code. requestSlots asks for the
config of the regions actually mounted: calls made in the same tick are batched into one request
scoped to exactly those ids, and each id is requested at most once per client. Ids with nothing
published register as drafts; baselineTexts maps an id to the text the region shows today, sent
with that first registration (with the session id: the server stores a reported text only once
visitors from several networks, on sessions later scored human, agree on it — or when the owner
confirms it in the dashboard). onSlotsChanged subscribes to the answer landing and returns the
unsubscribe.
const unsubscribe = client.onSlotsChanged?.(() => {
const entry = client.getSlotConfig('hero-cta'); // null → keep rendering the original
});
client.requestSlots?.(['hero-cta'], { 'hero-cta': 'Start free trial' });Decision snapshot (pre-paint on return visits)
Every decide writes a snapshot (persona, confidence band, slot results, layout order) to
localStorage under _snt_snap:<apiKey>. On the next visit, apply it before paint:
import { renderPrePaintScript } from '@sentientui/core';
// In your HTML head (server-rendered), inline this script to apply the snapshot pre-paint —
// ONLY when the visitor's consent is known to be granted (or your site has no consent gate):
// it reads what the SDK stored on their device. It skips DNT/GPC browsers and snapshots
// older than 30 days itself. React apps: <SentientPersonaScript> takes the consent props.
const inline = consented ? renderPrePaintScript('pk_your_key') : '';(readSnapshot / writeSnapshot are internal helpers the SDK uses; see "Public API".)
First visit renders your baseline; the return visit adapts with zero flicker.
Keyless local mode
Without a valid pk_… key, development builds simulate decisions locally — deterministic per
session, zero network — via the separate entry @sentientui/core/local:
import { createLocalEngine } from '@sentientui/core/local';
const engine = createLocalEngine({ sessionId, forcedPersona: 'evaluator' });
const outcome = engine.decide({ slots: [{ id: 'hero', dims: { tone: ['calm', 'urgent'] } }] });The local engine ships behind development/production export conditions — production bundles
physically contain none of it. Production without a key short-circuits to defaults and logs one
console.error per page. Force personas with ?sentient_persona=.
Optional: graph mode
@sentientui/core/graph is a separate, tree-shakable entry containing the graph-capable client (DOM scanner + graph sync: component-to-component edges + 2-hop reward propagation). A bare import('@sentientui/core/graph') has no effect — the entry exports its own init, which you must call with graph: true instead of the lean init:
import { init } from '@sentientui/core/graph';
const client = init({ apiKey: 'pk_…', graph: true });A client created by the lean init can never activate graph mode later (getGraph() stays empty). The lean client is ~17 KB gzip; the graph entry, with the chunks it shares with the lean client, ~22 KB. Both have CI budgets (scripts/size-check.ts).
Using
@sentientui/react? Don't import this entry yourself — the provider enables graph scanning by default (opt out withenableGraph={false}onAdaptiveProvider/AdaptiveRoot) and wires the graph-capableinit()into its single client for you; callinginitfrom this entry alongside the provider would create a second client.
There is also a lazy @sentientui/core/engagement entry (startEngagementCapture)
that classifies page sections and records per-section attention (dwell/scroll) to
power audience profiles. The React provider and the no-code snippet start it by
default; it never runs for a DNT/GPC or consent-gated visitor.
Local overrides (development — @sentientui/react only)
Dev overrides are implemented by the React SDK, not this package — client.assign() here does not read them. With @sentientui/react:
# URL parameter (stackable)
https://yourapp.com?sentient_variant=hero_cta:variant_a
# Or before SDK init:
window.__sentient_overrides = { hero_cta: 'variant_a' };While a variant is forced, the React components record nothing — no exposure, no goals, no micro-signals — so the bandit's weights are untouched.
Docs
Full reference: sentient-ui.com/docs.
License
MIT
