SentientUI

SDK Documentation

@sentientui/react v0.8 — adaptive UI with automatic variant learning

Overview

SentientUI is intent-adaptive UI for React: behavioural portraits and persona clusters drive which variant each visitor sees, trained from conversion rewards on SentientUI's hosted API (you do not deploy the learning stack). Install the open SDK from npm; add API keys from the dashboard.

It works in any React app and has first-class support for Next.js App Router with server-side rendering — decisions from api.sentient-ui.com are preloaded so variants appear in the initial HTML for SEO and zero layout shift on first paint.

Installation

Install the React package. The core engine is included automatically as a dependency.

npm install @sentientui/react
# or
yarn add @sentientui/react
# or
pnpm add @sentientui/react

You only ever import from @sentientui/react. The @sentientui/core package is the framework-agnostic engine — it ships as a dependency and you do not need to install or import it directly.

API key

Each project gets an API key when you create it in the dashboard, and you can rotate or add more from Project → Settings → API keys. Keys look like pk_xxxxxxxxxxxxxxxx. The full key is only shown once at creation — copy it before closing the dialog. If you lose it, revoke it and generate a new one. Keys are stored hashed (SHA-256 + server-side pepper); we never see the plaintext after issuance.

The key starts with pk_ (public key). It is safe to expose in browser bundles — the API enforces an allowed-origins allowlist on every request so other domains cannot use your key. Add your production domain in Settings → Allowed origins.

Environment variables

For Next.js you need the same key in two variables. This is a Next.js requirement: variables without the NEXT_PUBLIC_ prefix are only available on the server; the browser SDK needs a NEXT_PUBLIC_ copy to initialise on the client.

# .env.local

# Your API key — set the same value in both variables
SENTIENT_API_KEY=pk_your_key_here
NEXT_PUBLIC_SENTIENT_API_KEY=pk_your_key_here
That's it — just one key, set twice. The SDK points to the SentientUI API automatically.

Setup — Next.js App Router

Wrap your root layout with <AdaptiveRoot>. This is a server component that fetches variant assignments before the HTML is sent to the browser, so every component renders with real content on the first paint — no loading states, no layout shift, and crawlers see the actual variant markup.

// app/layout.tsx
import { AdaptiveRoot } from '@sentientui/react/next';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <AdaptiveRoot
          components={[
            { id: 'hero_cta',  variantIds: ['control', 'variant_a'] },
            { id: 'pricing',   variantIds: ['monthly', 'annual_first'] },
          ]}
          serverApiKey={process.env.SENTIENT_API_KEY!}
          apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
          context="saas"
        >
          {children}
        </AdaptiveRoot>
      </body>
    </html>
  );
}

AdaptiveRoot props

componentsArray<{ id: string; variantIds: string[] }>
List every <Adaptive> component you want server-side preloaded. The id must match exactly what you pass to <Adaptive id="...">. The variantIds must include all variant keys you define in the variants prop.
serverApiKeystring
Your API key used for server-side assignment requests. Use the non-NEXT_PUBLIC_ env var so it is never exposed in the client bundle.
apiKeystring
Same API key as serverApiKey, but as a NEXT_PUBLIC_ variable so it is available to the browser SDK for client-side event tracking.
context'saas' | 'landing' | 'ecommerce' | 'marketplace'
The type of product. See Context types below.
consentboolean (default: true)
Set to false to prevent the SDK from initialising. No events are sent, no cookies are written. Flip to true once the visitor grants consent. See Consent / GDPR below.
appOriginstring (default: 'http://localhost:3001')
The origin of your app, e.g. https://yourapp.com. Must match a value in the project's allowed origins list. Used in server-side assignment and session requests. Always set this in production — the default is only suitable for local development.
ssrFallback'first' | 'none' (default: 'first')
What to render during SSR when a component is not in the components preload list. 'first' renders the first variant key (safe for SEO). 'none' renders nothing (use with clientOnly for decorative slots).

Setup — other React apps

For Vite, Create React App, Remix, or any React app without Next.js App Router, use <AdaptiveProvider> directly. Variants are assigned on the client after mount — there is no SSR preloading, but the bandit still learns and optimises normally.

// main.tsx or App.tsx
import { AdaptiveProvider } from '@sentientui/react';

export default function App() {
  return (
    <AdaptiveProvider
      apiKey="pk_your_key_here"
      context="saas"
    >
      <YourApp />
    </AdaptiveProvider>
  );
}

<AdaptiveProvider> accepts the same context, consent, ssrFallback, and onAssignment props as <AdaptiveRoot>, plus initialAssignments if you are handling SSR yourself (see SSR — Pages Router below), and debug?: boolean to log assignment and event activity to the console.

<Adaptive> component

The main building block. Wrap any piece of UI you want to test. The bandit picks which variant to show, tracks impressions automatically, and records a reward when the goal fires.

import { Adaptive } from '@sentientui/react';

<Adaptive
  id="hero_cta"
  goal="signup_click"
  variants={{
    control:   <button className="btn-dark">Start free trial</button>,
    variant_a: <button className="btn-blue">Get instant access →</button>,
  }}
/>

Props

idstring
Unique identifier for this component within your project. Use the same string everywhere this component appears. Must match the id in your AdaptiveRoot components list if you want SSR preloading.
variantsRecord<string, ReactNode>
A plain object mapping variant IDs to React content. Keys are your own strings — use anything descriptive (control, variant_a, short, with_image, etc.). You can have two or more variants. The bandit will explore all of them and exploit the winner over time.
goalstring | GoalConfig
What counts as a conversion for this component. See Goal types below. Required.
clientOnlyboolean (default: false)
When true, the component renders nothing on the server and waits until the client has hydrated and a provider context is available. Use this only for slots that depend on a browser-only value (e.g. a "welcome back" banner that reads a cookie). When false (the default), the component SSR-renders the preloaded variant — or the ssrFallback variant when no preloaded assignment exists — which is recommended for above-the-fold content. Do not use clientOnly on CTAs or any component that must always be visible: if there is no provider context (e.g. consent={false} or AdaptiveRoot is not rendered), a clientOnly component renders nothing — not even after hydration.

<AdaptiveText> component

<AdaptiveText> is a lightweight wrapper for text-only variants — headlines, button labels, subheadings, and any copy you want to iterate on without touching code. Variant content is stored on the SentientUI API and editable from the dashboard without a redeploy.

import { AdaptiveText } from '@sentientui/react';

<h1>
  <AdaptiveText
    id="hero_headline"
    default="The fastest way to build adaptive UIs"
    component="span"
  />
</h1>

<AdaptiveText> renders a <span> by default. Pass component="h1", component="p", or any HTML tag to control the element. The winning copy is fetched from the API — update it in the dashboard without redeploying. Pair with a nearby <Adaptive> if you need automatic goal tracking on the same section.

<AdaptiveText> tracks impressions only — it does not accept a goal prop. Create variant IDs in Project → Components → Add variant. The default prop is the fallback shown during SSR and while the API response is in flight.

Components vs full pages

<Adaptive> works at any granularity — a button, a hero section, or an entire page layout. However, the right tool depends on what you are testing.

Use <Adaptive> for isolated components

This is the recommended pattern. Wrap a CTA, pricing block, banner, or any self-contained piece of UI. The component adds a wrapper <div> around its content, which is fine for most layouts and enables automatic impression tracking, goal wiring, and the HTML preview in the dashboard.

<Adaptive
  id="hero_cta"
  goal="signup_click"
  variants={{
    control:   <button>Start free trial</button>,
    variant_a: <button>Get instant access →</button>,
  }}
/>

Use useAssignment for full-page or route-level tests

When you want to test entire page layouts, the wrapper <div> that <Adaptive> renders can break your root CSS (flex/grid direct children, body margin, etc.). For those cases, use the useAssignment hook directly — you get the assigned variant ID and branch your own JSX with no wrapping element.

import { useAssignment, useSentient } from '@sentientui/react';

function LandingPage() {
  const client = useSentient();
  const { variantId } = useAssignment('landing_layout', ['control', 'sidebar']);

  // Fire the goal manually wherever it makes sense in your page.
  function handleSignup() {
    client?.track({
      componentId: 'landing_layout',
      variantId: variantId!,
      eventType: 'goal_achieved',
      goalType: 'signup',
      payload: { reward: 1.0 },
    });
  }

  return variantId === 'sidebar'
    ? <SidebarLayout onSignup={handleSignup} />
    : <StackedLayout  onSignup={handleSignup} />;
}
When using useAssignment directly you are responsible for tracking events. The automatic impression (variant_assigned) and goal (goal_achieved) tracking that <Adaptive> provides does not apply.

Two other limitations apply specifically to full-page use: the previewHtml capture that populates the dashboard preview is capped at 30 KB and will be a truncated, style-less snapshot of a full page — not useful. And the current automatic goal types (click, scroll_depth) track interactions within the component boundary; for page-level conversions like sign-up or purchase completion you will always want a manual client.track() call anyway.

Goal types

The goal prop defines what the bandit treats as a successful outcome. When the goal fires, the currently shown variant receives a reward of 1.0 and the bandit updates its weights. Each goal fires at most once per variant mount — a second click on the same variant does not double-count.

Click — string shorthand

<Adaptive id="cta" goal="signup_click" variants={...} />

// Any click on a <button>, <a>, or role="button" element inside
// the variant fires the goal. The string is recorded as the goal
// label in your analytics. Use any descriptive name.

Click — explicit object

<Adaptive
  id="cta"
  goal={{ type: 'click' }}
  variants={...}
/>

// Identical behaviour to the string shorthand.

Scroll depth

<Adaptive
  id="banner"
  goal={{ type: 'scroll_depth', threshold: 0.8 }}
  variants={...}
/>

// Fires when at least 80% of the component is visible in the
// viewport. Uses IntersectionObserver — no scroll listeners.
//
// threshold: number 0–1
//   0.5  = half visible
//   1.0  = fully visible (the component has been completely read)
//
// Useful for banners, feature sections, and content you want
// visitors to actually see before counting a conversion.

Form submit

<Adaptive
  id="signup_form"
  goal={{ type: 'form_submit' }}
  variants={{
    control:   <SignupFormA />,
    short:     <SignupFormB />,
  }}
/>

// Fires when a <form> element inside the variant fires a submit
// event — whether triggered by a button click, keyboard Enter, or
// programmatic form.submit(). Fires at most once per variant mount.
//
// Use this instead of goal="click" when your variant contains a form,
// because a click goal only triggers on <button> and <a> elements
// and misses keyboard submissions.

Composite — all sub-goals must fire

<Adaptive
  id="feature_section"
  goal={{
    type: 'composite',
    all: [
      { type: 'scroll_depth', threshold: 0.8 },
      { type: 'click' },
    ],
  }}
  variants={...}
/>

// Both sub-goals must fire (in any order) before the reward is
// recorded. Useful for "read AND clicked" patterns — ensures the
// variant earned the conversion rather than getting lucky clicks
// from visitors who never saw the content.
//
// 'all' accepts click, scroll_depth, and form_submit sub-goals.

Composite — read the copy, then submit

<Adaptive
  id="pricing_block"
  goal={{
    type: 'composite',
    all: [
      { type: 'scroll_depth', threshold: 0.75 },
      { type: 'form_submit' },
    ],
  }}
  variants={{
    monthly_first: <PricingWithMonthlyDefault />,
    annual_first:  <PricingWithAnnualDefault />,
  }}
/>

// Reward fires only when the visitor scrolled through most of the
// pricing table AND submitted the trial form — not on accidental
// clicks from visitors who never read the plans.

Weighted composite goals

weighted_composite gives each step in a multi-step funnel its own fractional reward. Steps fire independently as they complete — the bandit does not wait for all steps. A visitor who reads the pricing section (weight 0.2) but never signs up still generates signal, so the bandit converges 3–5× faster on long funnels.

<Adaptive
  id="checkout-flow"
  variants={{ a: <CheckoutA />, b: <CheckoutB /> }}
  goal={{
    type: 'weighted_composite',
    steps: [
      { goal: { type: 'scroll_depth', threshold: 0.5 }, name: 'viewed_pricing', weight: 0.2 },
      { goal: { type: 'click' },                         name: 'clicked_cta',   weight: 0.4 },
      { goal: { type: 'form_submit' },                   name: 'signed_up',     weight: 1.0 },
    ],
  }}
/>

// Each step fires at most once per mount.
// Steps are independent — step 2 can fire before step 1.
// Named steps appear in the Goals analytics screen with their weights.

Weighted composite — onboarding wizard in one component

<Adaptive
  id="onboarding_wizard"
  variants={{ steps_sidebar: <WizardSidebar />, steps_top: <WizardTopNav /> }}
  goal={{
    type: 'weighted_composite',
    steps: [
      { goal: { type: 'click' },                        name: 'started_wizard', weight: 0.15 },
      { goal: { type: 'scroll_depth', threshold: 0.6 }, name: 'reached_step_2', weight: 0.45 },
      { goal: { type: 'form_submit' },                 name: 'workspace_ready', weight: 1.0  },
    ],
  }}
/>

// Steps fire independently as the visitor progresses. Partial
// credit (0.15, 0.45) reaches the bandit before the final form
// submit — useful when full completion is under ~5% of sessions.

Use weighted_composite whenever full-funnel completion is rare enough that the bandit would take weeks to distinguish good variants from bad ones. Use composite (all-or-nothing) when you specifically want the bandit to reward only the variant that completed the full flow.

Custom goals

The built-in goal types cover interactions inside a single <Adaptive> boundary. For conversions that happen elsewhere — after navigation, in an API route, or on a thank-you page — use client.goal() for funnel analytics and client.track({ eventType: 'goal_achieved' }) when you need the bandit to learn from a specific component assignment.

import { useSentient } from '@sentientui/react';

function CheckoutSuccessPage() {
  const client = useSentient();

  useEffect(() => {
    if (!client) return;

    // Session-level funnel step — appears in Goals analytics and
    // joins to whichever variants were shown this session.
    client.goal('checkout_complete', { plan: 'pro' }, 1.0, 2);

    // Bandit reward: credit the hero CTA variant that started this funnel.
    const hero = client.getAssignment('hero_cta', 'desktop:direct');
    if (hero) {
      client.track({
        componentId: 'hero_cta',
        variantId: hero.variantId,
        eventType: 'goal_achieved',
        payload: { reward: 1.0 },
      });
    }
  }, [client]);

  return <ThankYouMessage />;
}

client.goal(name, metadata?, weight?, stepIndex?) POSTs a named goal to /v1/goals (dashboard funnel charts). goal_achieved via client.track() updates variant weights. For cross-page funnels, call client.goal() at each step with increasing stepIndex and fractional weight values — same semantics as weighted_composite, but spanning routes.

Not sure what to track? On Starter and above, enable Observation Mode (management API) to pause bandit updates while the SDK collects behavioral signals for 500 sessions. After that, the dashboard surfaces "Moments we noticed" on the project overview — each with a ready-to-copy client.goal() snippet. You confirm or dismiss each one. See pricing.

Goal recipes

End-to-end patterns that combine declarative goal props with imperative client.goal() / client.track() calls. Copy and adapt to your routes.

Recipe: landing hero → pricing → purchase (cross-page funnel)

The hero uses a simple click goal. Intermediate steps fire on later pages. The thank-you page closes the loop for analytics and bandit credit.

// app/page.tsx — hero variant test
<Adaptive id="hero_cta" goal="start_trial_click" variants={{ ... }} />

// app/pricing/page.tsx — record mid-funnel intent
'use client';
import { useEffect } from 'react';
import { useSentient } from '@sentientui/react';

export default function PricingPage() {
  const client = useSentient();
  useEffect(() => {
    client?.goal('viewed_pricing', { source: 'nav' }, 0.25, 0);
  }, [client]);
  return <PricingTable />;
}

// app/checkout/success/page.tsx — terminal step + bandit reward
'use client';
import { useEffect } from 'react';
import { useSentient } from '@sentientui/react';

export default function SuccessPage() {
  const client = useSentient();
  useEffect(() => {
    if (!client) return;
    client.goal('purchase_complete', { currency: 'USD' }, 1.0, 2);
    const hero = client.getAssignment('hero_cta', 'desktop:direct');
    if (hero) {
      client.track({
        componentId: 'hero_cta',
        variantId: hero.variantId,
        eventType: 'goal_achieved',
        payload: { reward: 1.0 },
      });
    }
  }, [client]);
  return <p>Thanks for your order.</p>;
}
Pass the same segment string to getAssignment() that the SDK uses internally (e.g. desktop:direct). Import deriveSessionSegment from @sentientui/core if you build it from user-agent and referrer on the server.

Recipe: Stripe webhook → server-side goal

Payment providers confirm on the server. Forward the session ID from checkout metadata and call the management API from your webhook handler — no browser required.

// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const event = await stripe.webhooks.constructEvent(/* ... */);
  if (event.type !== 'checkout.session.completed') {
    return Response.json({ received: true });
  }

  const sessionId = event.data.object.metadata?.sentient_session_id;
  if (!sessionId) return Response.json({ received: true });

  await fetch('https://api.sentient-ui.com/v1/goals', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.NEXT_PUBLIC_SENTIENT_API_KEY}`,
      Origin: process.env.APP_ORIGIN!,
    },
    body: JSON.stringify({
      sessionId,
      name: 'stripe_checkout_complete',
      metadata: { amount: event.data.object.amount_total },
      weight: 1.0,
      stepIndex: 0,
    }),
  });

  return Response.json({ received: true });
}

// When creating the Stripe Checkout session, stash the Sentient session:
// metadata: { sentient_session_id: cookies.get('_sentient_sid') }

Recipe: full-page layout test with manual goals

When useAssignment drives an entire page, wire goals at the exact business-logic boundary — signup handler, modal confirm, etc.

import { useAssignment, useSentient, useAdaptiveApiKey } from '@sentientui/react';

function LandingPage() {
  const client = useSentient();
  const apiKey = useAdaptiveApiKey();
  const { variantId } = useAssignment('landing_layout', ['control', 'sidebar_nav']);

  async function handleSignup(data: FormData) {
    const ok = await createAccount(data);
    if (!ok || !client || !variantId) return;

    // Analytics label for the Goals tab
    client.goal('account_created', { method: 'email' }, 1.0, 0);

    // Bandit update for this layout variant
    client.track({
      projectId: apiKey,
      componentId: 'landing_layout',
      variantId,
      eventType: 'goal_achieved',
      goalType: 'account_created',
      payload: { reward: 1.0 },
    });
  }

  return variantId === 'sidebar_nav'
    ? <SidebarLayout onSignup={handleSignup} />
    : <ClassicLayout onSignup={handleSignup} />;
}

Recipe: "quality engagement" composite on a feature block

<Adaptive
  id="feature_comparison"
  goal={{
    type: 'composite',
    all: [
      { type: 'scroll_depth', threshold: 0.9 },
      { type: 'click' },
    ],
  }}
  variants={{
    table:  <ComparisonTable />,
    cards:  <ComparisonCards />,
  }}
/>

// The bandit only rewards variants where visitors actually read
// the comparison (90% visible) and then clicked a CTA — filtering
// out bounce clicks from skimmers.

Layout optimization

Add a sections prop to <AdaptiveRoot> to enable persona-aware section ordering. Instead of individual /v1/assign calls per component, a single POST /v1/decide request returns the optimal section sequence and all component assignments together. The order is locked per session so the page layout is stable across navigations.

// app/layout.tsx
import { AdaptiveRoot } from '@sentientui/react/next';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <AdaptiveRoot
          sections={['hero', 'pricing', 'features', 'social_proof']}
          components={[
            { id: 'hero_cta', variantIds: ['control', 'variant_a'] },
          ]}
          serverApiKey={process.env.SENTIENT_API_KEY!}
          apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
          context="saas"
        >
          {children}
        </AdaptiveRoot>
      </body>
    </html>
  );
}

The sections array defines the default order — what visitors see before their persona is determined, and the fallback when confidence is below threshold (0.3). Always pass this same array as the fallback in your page component.

useLayoutOrder hook

Read the resolved section order on the client. Returns the SSR-preloaded order on first render with no layout shift, or null when sections are not configured or the visitor's persona confidence is below threshold. Always fall back to the default order.

import { useLayoutOrder } from '@sentientui/react';

function Page() {
  const order = useLayoutOrder();
  // ['pricing', 'hero', 'features', 'social_proof'] | null

  const sections: Record<string, React.ReactNode> = {
    hero:         <HeroSection />,
    pricing:      <PricingSection />,
    features:     <FeaturesSection />,
    social_proof: <SocialProofSection />,
  };

  const defaultOrder = ['hero', 'pricing', 'features', 'social_proof'];

  return (
    <main>
      {(order ?? defaultOrder).map((id) => (
        <React.Fragment key={id}>{sections[id]}</React.Fragment>
      ))}
    </main>
  );
}
useLayoutOrder() is exported from @sentientui/react. It works with both AdaptiveRoot (App Router) and AdaptiveProvider (client-only), but SSR preloading — zero layout shift on first paint — requires AdaptiveRoot.

Shadow mode

Enable shadow mode from the project settings before going live. The API runs the full personalization algorithm and logs the order it would have served, but always delivers the default order to visitors. Once you have enough data (100+ shadow decisions recommended), review the per-persona section rankings in the Layout tab in the dashboard, then click Go live to start serving personalized layouts.

The Layout tab shows pulls and average reward per layout order per persona. Higher average reward means visitors with that persona converted better under that section sequence.

Note: layout personalization requires at least 4 reliable visitor portraits before the first cluster run. On a new project, variants are still assigned and learned from day one using device and traffic-source segmentation — only the section ordering waits for clustering.

AdaptiveRoot — sections prop

sectionsstring[] (optional)
Declare page section IDs in their default order. When provided, AdaptiveRoot calls POST /v1/decide instead of individual /v1/assign calls, and useLayoutOrder() returns the persona-specific order on first render. IDs can be any string — they are used as keys in useLayoutOrder() and tracked in the Layout tab.

Personas

Layout ordering and component assignment are driven by behavioral personas — clusters inferred automatically from how visitors interact with your product. You do not configure personas; the API derives them from each visitor's behavioral portrait over time.

PersonaBehavioral signalTypical layout preference
buyerClicks quickly, skips long-form content, high conversion intentCTA and pricing early
researcherHigh scroll depth, reads feature details, low first-session conversionFeatures and proof before pricing
deal_seekerFocuses on pricing section, compares plans, discount-sensitivePricing prominent, social proof nearby
browserLow engagement, shallow scroll, unclear intentDefault order — no strong preference detected

A persona is assigned once the visitor's behavioral portrait reaches a confidence score of 0.3 or higher — typically after several page interactions across one or more sessions. Below that threshold, the default section order is used and component assignment falls back to unweighted exploration.

Clustering also requires at least 4 reliable sessions project-wide and runs as a nightly job — new projects see device/source-based variant learning immediately, but layout reordering activates after the first cluster run.

Portrait confidence is also reflected in the confidence field returned by POST /v1/decide. You can inspect the current persona and confidence in the Visitors tab of the dashboard for any individual session.

Personas are derived from anonymous behavioral signals only — no personal data is collected or stored. The _snt_uid cookie links events to a session; it contains no identifying information.

Cross-session identity

By default, each browser session is anonymous. Call client.identify(userId) once your user is authenticated to link their behavioral portrait to a stable identity. Future sessions from the same user ID — on any device — resume from the same portrait.

import { useSentient } from '@sentientui/react';

function App() {
  const client = useSentient();
  const { user } = useAuth(); // your auth provider

  useEffect(() => {
    if (user?.id) {
      // Links this browser session to the user's portrait.
      // Safe to call on every mount — no-ops if already identified.
      client?.identify(user.id);
    }
  }, [user?.id, client]);

  return <YourApp />;
}

Identified portraits are more reliable — the bandit has more signal per user, persona clustering is more accurate, and the cross-session continuity means returning visitors see consistent variants immediately without a re-exploration period. The userId you pass is stored hashed and is never exposed in the dashboard or API responses.

Call identify() after the user has authenticated — not on every render. Calling it with different IDs for the same session will create separate portrait entries. For guest-to-authenticated transitions, the anonymous session portrait is carried forward automatically when identify() is first called.

Context types

The context prop describes the type of product. It is stored with the project and used to inform segment weighting and analytics grouping.

ValueUse case
saasSaaS products, dashboards, onboarding flows, upgrade prompts
landingMarketing sites, landing pages, waitlists, announcement pages
ecommerceProduct pages, collection listings, carts, checkout flows
marketplaceTwo-sided marketplaces, listing pages, search results

useAssignment hook

Use this when you need the assigned variant ID inside your own component logic rather than passing content as variants props.

import { useAssignment } from '@sentientui/react';

function Hero() {
  const { variantId, isLoading } = useAssignment(
    'hero_cta',                    // component id
    ['control', 'variant_a'],      // all possible variant ids
  );

  if (!variantId) return <HeroSkeleton />;

  return variantId === 'variant_a'
    ? <AccentHero />
    : <DefaultHero />;
}
variantIdstring | null
The assigned variant ID. null during SSR before any assignment is available (only when not using initialAssignments).
isLoadingboolean
true while the first assignment fetch is in flight. Typically falseimmediately when initialAssignments are provided via AdaptiveRoot.
Important: useAssignment does not track impressions or wire up goal events automatically. If you use the hook directly instead of <Adaptive>, you are responsible for tracking events yourself via the client.track() method from useSentient(). Use <Adaptive> whenever possible.

SSR — Pages Router

For Next.js Pages Router or any custom SSR setup, use loadAdaptiveAssignments to fetch variant assignments on the server and pass them to the provider as initialAssignments. This helper reads the session cookie automatically — you don't need to handle it yourself.

// pages/_app.tsx
import { AdaptiveProvider } from '@sentientui/react';

export default function App({ Component, pageProps }) {
  return (
    <AdaptiveProvider
      apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
      context="saas"
      initialAssignments={pageProps.initialAssignments ?? {}}
    >
      <Component {...pageProps} />
    </AdaptiveProvider>
  );
}

// pages/index.tsx
import { loadAdaptiveAssignments } from '@sentientui/react/server';

export async function getServerSideProps({ req }) {
  const assignments = await loadAdaptiveAssignments(
    [
      { id: 'hero_cta',  variantIds: ['control', 'variant_a'] },
      { id: 'pricing',   variantIds: ['monthly', 'annual_first'] },
    ],
    {
      cookies: req.cookies,
      apiKey:  process.env.SENTIENT_API_KEY,
      origin:  process.env.SENTIENT_APP_ORIGIN, // must be in allowed origins
    },
  );

  return { props: { initialAssignments: assignments } };
}

The SDK writes a session cookie (_snt_uid) and sends events to the API. If your product requires explicit consent before setting cookies or tracking behaviour, use the consent prop.

const [consented, setConsented] = useState(false);

<AdaptiveProvider
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
  context="saas"
  consent={consented}        // false = SDK not initialised
>
  <CookieBanner onAccept={() => setConsented(true)} />
  {children}
</AdaptiveProvider>

When consent={false}: no SDK is initialised, no cookies are written, no events are sent, and <Adaptive> components without clientOnly fall back to ssrFallback behaviour (rendering the first variant by default). Components with clientOnly={true} render nothing — the provider context is required for them to hydrate. Avoid clientOnly on any component that must remain visible to non-consenting visitors. Flipping consent to true initialises the SDK and begins tracking from that point.

The _snt_uid cookie is a first-party session identifier. It contains no personal data — only a random UUID used to maintain session continuity across page loads. See the Privacy page for a full breakdown of what SentientUI collects and how long data is retained.

Forwarding to your own analytics

Pass onAssignment to the provider to be notified once per component the first time a variant is resolved in that session. Use this to send the assignment to Mixpanel, PostHog, Segment, or any other tool without wrapping every <Adaptive> manually.

<AdaptiveProvider
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
  context="saas"
  onAssignment={(componentId, variantId) => {
    // Called once per component per session when the variant is first resolved.
    mixpanel.register({ [`variant_${componentId}`]: variantId });
    posthog.capture('$feature_flag_called', { flag: componentId, variant: variantId });
    analytics.track('Variant Assigned', { componentId, variantId });
  }}
>
  {children}
</AdaptiveProvider>
onAssignment fires at most once per component ID per page load, even if the component re-renders. It does not fire for dev overrides — see Local overrides below.

Local overrides (development)

Force a specific variant without touching the bandit — useful for QA, visual testing, and building new variants before they go live.

URL parameter

Append sentient_variant=componentId:variantId to any page URL. Repeat for multiple components. Works in any environment where the URL is accessible.

# Force a single component
https://yourapp.com/pricing?sentient_variant=hero_cta:variant_a

# Force multiple components at once (repeat the param)
https://yourapp.com/pricing?sentient_variant=hero_cta:variant_a&sentient_variant=pricing:annual_first

Global object

Set window.__sentient_overrides before the SDK initialises. Useful for Storybook, Playwright, or any test harness where you control the page environment.

// In a test setup file or Storybook decorator:
window.__sentient_overrides = {
  hero_cta: 'variant_a',
  pricing:  'annual_first',
};
Overrides are client-only and log a console message in non-production environments so you can confirm which component is being overridden. Overrides bypass the bandit entirely — no events are recorded and the variant weights are not affected.

Agent API

SentientUI serves structured variant content to AI agents — LLMs and orchestration frameworks that need to read the winning layout for a visitor without rendering a browser. This is useful for AI-driven email generation, chat personalisation, agent workflows, and any non-browser surface that should respect the same A/B decisions as your web UI.

Annotate variants with machine-readable content using the agentData prop:

<Adaptive
  id="pricing_cta"
  goal="trial_started"
  variants={{
    control:   <button>Start free trial</button>,
    value_led: <button>Start free — no card required</button>,
  }}
  agentData={{
    control:   { label: 'Start free trial', tone: 'neutral' },
    value_led: { label: 'Start free — no card required', tone: 'reassuring' },
  }}
/>

Then call GET /v1/agent/layout from your AI system to get the resolved variant content for a specific visitor:

// From your backend or AI agent — use your server (sk_) key
const res = await fetch(
  'https://api.sentient-ui.com/v1/agent/layout?visitor_id=<uuid>',
  { headers: { Authorization: 'Bearer sk_your_key' } }
);

const { persona, confidence, blocks } = await res.json();
// blocks: [{ block: 'pricing_cta', variant: 'value_led', content: { label: '...', tone: '...' } }]
visitor_idquery param — UUID
The visitor's session ID from the _snt_uid cookie. The API resolves the persona and returns the winning variants for that visitor. Pass user_id instead if you identified the visitor with client.identify().
personastring | null
The visitor's persona cluster (buyer, researcher, etc.), or null if the portrait is not yet reliable enough.
confidencenumber | null
Portrait reliability score (0–1). null for anonymous or unresolved visitors.
blocksArray<{ block, variant, content }>
One entry per <Adaptive> component that has agentData defined. content is the agentData value for the winning variant.
The agent layout endpoint uses server keys (sk_), not public keys (pk_). Generate one in Project → Settings → API key → Server key (Starter and Growth plans). Every call is logged to agent_events — visible as the Agent Calls count on the project health screen.

MCP server

@sentientui/mcp exposes your project data and management actions as tools to AI assistants in Claude Code, Cursor, and Copilot via the Model Context Protocol. Once configured, you can ask your IDE assistant questions like “Are any variants paused?” or “What changed in CVR this week?” without leaving your editor.

// ~/.claude/settings.json  (Claude Code)
// ~/.cursor/mcp.json        (Cursor)
{
  "mcpServers": {
    "sentientui": {
      "command": "npx",
      "args": ["-y", "@sentientui/mcp"],
      "env": { "SENTIENTUI_API_KEY": "sk_your_key_here" }
    }
  }
}

The server key is the same sk_ key as the agent API — generate one in Project → Settings → API key → Server key (Starter+). Restart your IDE after saving the config.

Available tools: list_projects, get_project_stats, list_components, get_variant_performance, get_insights, refresh_insights, get_persona_breakdown, get_goal_funnel, list_guardrail_events, get_layout_stats, create_variant, pause_variant.

No account yet? Run npx @sentientui/mcp without setting SENTIENTUI_API_KEY — a sandboxed demo token is provisioned automatically with 10 calls/month.

How it works

Each <Adaptive> component runs an independent Thompson Sampling bandit. The bandit operates per segment — a combination of the visitor's device class and traffic source.

DimensionValues
Device classmobile, tablet, desktop
Traffic sourcedirect, search, social, referral

This means mobile visitors from search may have a different winning variant than desktop visitors arriving directly. The bandit learns independently per segment — no manual cohort setup required.

Assignment flow

1. Visitor arrives → session created (or resumed from cookie)
2. AdaptiveRoot (or useAssignment) calls POST /v1/assign
   → API looks up segment weights for this (component, segment) pair
   → Thompson Sampling: sample Beta(alpha, beta) per variant, serve the argmax
   → returns variantId
3. Component renders the assigned variant
4. SDK sends a variant_assigned event (impression)
5. When the goal fires → SDK sends goal_achieved event
6. API updates variant weights: alpha/beta shift toward the winner
7. Over time: uncertainty shrinks, confident winners dominate

The learning progress bar in the dashboard tracks bandit pulls toward a configurable target (default 500). Below that threshold the algorithm is still actively exploring. Above it, confidence is high enough to consider promoting a winner manually or letting the bandit converge fully.

Events are batched in memory and flushed every 5 seconds, or immediately when the page becomes hidden. No data is lost on tab close — the SDK uses fetch with keepalive: true for unload-safe delivery.

SentientUI — SDK Docs — SentientUI