npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/react instead — it bundles this package and adds the SSR-safe <AdaptiveRoot>, <Adaptive>, and hooks. Use @sentientui/core directly only if you are not building with React.

Installation

npm install @sentientui/core

Quick 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, default 1.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 (default 0) — 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/goals with no component/variant, so it appears in funnel charts but not the per-variant CVR breakdown. For variant experiments, prefer componentGoal() (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 every SentientClient method documented above
  • grantConsent(apiKey?) — upgrade a client created with consent: false
  • isDoNotTrackEnabled() — whether DNT/GPC gates tracking in this browser
  • deriveSessionSegment({ userAgent, referer, appOrigin }) — the device:source segment key
  • setCspNonce(nonce) — CSP nonce for injected <style> (or pass nonce to init)
  • renderPrePaintScript(apiKey) — the inline pre-paint script (see "Decision snapshot"); render it only once consent is known
  • forgetVisitor(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 with enableGraph={false} on AdaptiveProvider / AdaptiveRoot) and wires the graph-capable init() into its single client for you; calling init from 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