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/reactYou 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.
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_hereSetup — 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[] }><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.serverApiKeystringNEXT_PUBLIC_ env var so it is never exposed in the client bundle.apiKeystringserverApiKey, but as a NEXT_PUBLIC_ variable so it is available to the browser SDK for client-side event tracking.context'saas' | 'landing' | 'ecommerce' | 'marketplace'consentboolean (default: true)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')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')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
idstringid in your AdaptiveRoot components list if you want SSR preloading.variantsRecord<string, ReactNode>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 | GoalConfigclientOnlyboolean (default: false)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} />;
}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.
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>;
}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)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.
| Persona | Behavioral signal | Typical layout preference |
|---|---|---|
| buyer | Clicks quickly, skips long-form content, high conversion intent | CTA and pricing early |
| researcher | High scroll depth, reads feature details, low first-session conversion | Features and proof before pricing |
| deal_seeker | Focuses on pricing section, compares plans, discount-sensitive | Pricing prominent, social proof nearby |
| browser | Low engagement, shallow scroll, unclear intent | Default 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.
_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.
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.
| Value | Use case |
|---|---|
| saas | SaaS products, dashboards, onboarding flows, upgrade prompts |
| landing | Marketing sites, landing pages, waitlists, announcement pages |
| ecommerce | Product pages, collection listings, carts, checkout flows |
| marketplace | Two-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 | nullnull during SSR before any assignment is available (only when not using initialAssignments).isLoadingbooleantrue while the first assignment fetch is in flight. Typically falseimmediately when initialAssignments are provided via AdaptiveRoot.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 } };
}Consent / GDPR
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.
_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_firstGlobal 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',
};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_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 | nullbuyer, researcher, etc.), or null if the portrait is not yet reliable enough.confidencenumber | nullnull for anonymous or unresolved visitors.blocksArray<{ block, variant, content }><Adaptive> component that has agentData defined. content is the agentData value for the winning variant.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.
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.
| Dimension | Values |
|---|---|
| Device class | mobile, tablet, desktop |
| Traffic source | direct, 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 dominateThe 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.

