SentientUI

SDK Documentation

@sentientui/react — audiences out of the box: styling, content, and section order per visitor type, no tagging required

Overview

SentientUI is intent-adaptive UI for React: visitor profiles and audience groups drive which variant each visitor sees, trained from conversion scores on SentientUI's hosted API (you do not deploy the learning stack). Install the open SDK from npm — the client source is on GitHub — and 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 — code variants listed in AdaptiveRoot are preloaded so they appear in the initial HTML for SEO and zero layout shift on first paint. Generated versions arrive just after the page loads: a first visit shows your original briefly before the version swaps in.

The adaptive ladder

One concept at four altitudes: a bounded, designer-approved piece of UI with a declared set of variations and a goal. Climb one rung at a time — every rung pays for itself. A visitor's first visit teaches Sentient who they are; the next visit shows them the page most likely to convert.

Rung 0 — Observe. Install is the integration: the dashboard immediately shows your traffic — and, as personas are declared by your app or discovered, how it splits — and suggests the next rung with a copy-pasteable snippet.

Rung 1 — Style (CSS only). The SDK sets data-sentient-persona and data-sentient-confidence on <html> before first paint — write plain CSS against them. Or declare a bounded token set and let the optimizer learn which look converts best per visitor type:

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

function Hero() {
  const t = useAdaptiveTokens('hero', {
    tone: ['calm', 'urgent'],   // first value = what you show today
  });
  return <section {...t.props} className="hero">…</section>;
}

/* CSS */
.hero[data-tone='urgent'] .cta { font-weight: 700; }
html[data-sentient-persona='admin'] .admin-tools { display: block; }
html[data-sentient-confidence='low'] .admin-tools { display: none; }

Rung 2 — Swap (alternate content). Headless variant selection with measurement built in — goal is required and bind must be attached:

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

const { value, bind } = useAdaptive('buy-box', {
  variants: { calm: <CalmBuyBox />, urgent: <UrgentBuyBox /> },  // first key = baseline
  goal: 'buy_click',
});
return <div {...bind}>{value}</div>;

Rung 3 — Reorder (structure). Designer-approved arrangements of keyed children — never free shuffling, and every decision is locked for the visit:

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

<AdaptiveGroup id="pricing-area" arrangements={{
  standard:     ['plans', 'faq', 'social'],   // first key = baseline
  social_first: ['social', 'plans', 'faq'],
}}>
  <PlanGrid key="plans" /> <Faq key="faq" /> <Testimonials key="social" />
</AdaptiveGroup>

Try it with no account — keyless local mode

npx @sentientui/cli init   # detects your framework, prints a wrap snippet, adds an example
# you still wrap <AdaptiveRoot> (or <AdaptiveProvider>) yourself — init does not edit layout
npm run dev
# open http://localhost:3000?sentient_persona=a, then ?sentient_persona=b — watch the page adapt

Without an API key, development builds simulate every decision on your machine — deterministic, zero network, nothing sent anywhere. Force any persona with ?sentient_persona= or the devtools panel. Add a pk_… key later to learn from real traffic; nothing else changes. Production bundles physically exclude the local engine.

Not using React? The @sentientui/snippet package brings Rung 1 to any site with one script tag — persona attributes plus learned style tokens, applied as data-* attributes. It never injects markup — the most it can move is a declared element among its own siblings — and any error leaves the page untouched. The install is two tags in <head>: one inline tag that sets window.sentient and applies the visitor's last decision before first paint, then the deferred loader — copy both from your project's Install page. Sites with a hash-based Content-Security-Policy use the three-tag form shown there instead: split out, the pre-paint tag is byte-identical on every site, so one hash covers it. Existing three-tag and two-tag installs keep working.

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 key. 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 designed for browser and SSR helpers: the API checks an allowed-origins allowlist so other websites cannot casually reuse your key from a visitor's browser. That allowlist is not cryptographic authentication — anyone who has the pk_ can set an Origin header from a server. Protect the key like any client credential, keep origins tight, rely on rate limits and session ownership checks, and record trusted server-side conversions with an sk_ key (accepted on /v1/goals and /v1/events without an Origin). Add your production domain in Project settings → Allowed origins.

Environment variables

Add your API key to .env.local. One variable is all you need.

# .env.local
NEXT_PUBLIC_SENTIENT_API_KEY=pk_your_key_here
That's it. The SDK points to the SentientUI API automatically.

Setup — Next.js App Router

Wrap your root layout with <AdaptiveRoot>. That is the whole setup: every <Adaptive> under it registers itself the first time it renders, with nothing to declare here.

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

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <AdaptiveRoot apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}>
          {children}
        </AdaptiveRoot>
      </body>
    </html>
  );
}

Optionally, <AdaptiveRoot> is a server component that can decide components before the HTML is sent, so they render with real content on the first paint instead of showing your original until the page loads — no layout shift, and crawlers see the served markup. List code-variant components in components and generated ones in registrySlotIds, using the same ids as on the page:

<AdaptiveRoot
  components={[{ id: 'hero_cta', variantIds: ['control', 'variant_a'] }]}  // <Adaptive id="hero_cta" variants={…}>
  registrySlotIds={['pricing-headline']}                                     // <Adaptive id="pricing-headline">
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
>
Only declare ids the wrapped pages actually render. A declared id is decided on every request <AdaptiveRoot> wraps — in app/layout.tsx that is every route — whether or not the component is on the page, and each of those counts as a view that can never convert. A component that appears on only some pages should stay undeclared.
AdaptiveRoot must be a Server Component. Do not add 'use client' to app/layout.tsx, and do not nest <AdaptiveRoot> inside any component that has it. If it ends up in a client boundary, the server-side /v1/decide call never fires — sessions will be created but no events, assignments, or dashboard data will appear.

AdaptiveRoot props

componentsArray<{ id: string; variantIds: string[] }> (optional)
Code-variant <Adaptive> components to decide server-side. The id must match exactly what you pass to <Adaptive id="...">, and variantIds must include every key in its variants prop. List only components rendered on every page this root wraps: each listed id records a view on every request, rendered or not. Omit and they are decided in the browser after mount.
registrySlotIdsstring[] (optional)
Generated <Adaptive>s (no variants) to decide server-side, so the first paint shows the published version instead of your original. Same rule: only ids rendered on every wrapped page. Omit and they swap in after mount.
apiKeystring
Your pk_… API key, set via NEXT_PUBLIC_SENTIENT_API_KEY. Used by both the browser SDK and server-side SSR requests — one env var is all you need.
context'saas' | 'landing' | 'ecommerce' | 'marketplace' (optional, deprecated)
Unused — the project's type is set in the dashboard. Safe to omit.
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.
respectDoNotTrackboolean (default: true)
Honour the browser's Do Not Track signal. When enabled and the visitor has DNT on, the SDK sets no cookies and sends no tracking data — even if consent is true — and grantConsent() will not re-enable it. Set to false to make your own consent gate authoritative. 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.
enableGraphboolean (default: true)
DOM graph scanning — on by default. The SDK captures your page structure, auto-detects what each section is (pricing, hero, social proof, …), and syncs it to power audience profiles and the DOM Graph page. Loaded on demand so the base bundle stays lean. Pass false to turn it off.
captureAgentsboolean (default: true)
Log JS-less AI-assistant fetches (GPTBot, ChatGPT-User, Claude-User, PerplexityBot, …) to your agent analytics. Because <AdaptiveRoot> is a Server Component it sees these requests even though they never run JavaScript — no middleware needed. Machine telemetry only: no cookie, no session, independent of consent / Do Not Track. See the AI agents view under Audiences. Set false to opt out.
engagementboolean (default: true)
Behavioral engagement capture — on by default. Records how long each page section is actually on screen and how far visitors scroll, which is what lets Sentient build audience profiles without any tagging. Never runs for a Do-Not-Track, Global Privacy Control, or consent-gated visitor. Pass false to turn it off.
noncestring
CSP nonce for the inline persona script (and the optional agent JSON-LD). Only needed if you run a strict Content-Security-Policy — pass the same nonce you set on the header, e.g. from Next.js middleware or headers(). Omit it if you don't use a nonce-based CSP.

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 AI optimizer 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"
    >
      <YourApp />
    </AdaptiveProvider>
  );
}

<AdaptiveProvider> accepts the same consent prop as <AdaptiveRoot>, plus onAssignment (not available on <AdaptiveRoot> — function props can't cross the RSC boundary), initialAssignments for Pages Router SSR, and debug?: boolean to log assignment and event activity to the console. DOM graph scanning and behavioral engagement capture are on by default (see enableGraph and engagement above to opt out).

Setup — Shopify and no-code sites

Sites not built with React — Shopify, Webflow, WordPress, plain HTML — use the script tag (@sentientui/snippet). It sets visitor-type attributes your CSS can react to, serves the components you publish from the dashboard's on-site editor (copy, styles and composition blocks — never raw HTML), and can reorder the page sections you list. Goals are defined in the editor too, so nothing else needs code.

Shopify

The SentientUI Shopify app installs the script through a theme app embed and forwards paid, refunded and cancelled orders, so revenue is measured from Shopify's own records. The app is not listed on the Shopify App Store yet: create a project with the Shopify framework, then request an install link from the Install page.

  1. Install the app, open it in your Shopify admin, paste the project's pk_ and sk_ keys, and save. The purchase goal, checkout funnel and refund netting are set up on save.
  2. In Online Store → Themes → Customize → App embeds, switch SentientUI on and save the theme.
  3. Place a test order: it appears as a purchase in your dashboard.

Consent follows Shopify's Customer Privacy API (consentFrom: 'shopify', snippet 0.32+), which Shopify's banner and Shopify cookie-banner apps report into — see Consent platforms.

Any other site

Copy the install tags from your project's Install page into the site's <head>. There are two: an inline tag that sets window.sentient and applies the visitor's last decision before the page paints, then the deferred loader. Then open the on-site editor from the dashboard to publish your first component. If the site has a cookie banner, add consentFrom to window.sentient (see Consent platforms).

How variants are created

There are three ways to fill a component. The difference is about where the content lives and how the variant gets registered — and it determines whether you need to do anything in the dashboard at all.

Generated versions (start here)

Wrap a section in <Adaptive> without a variants prop and your children become the original. SentientUI writes versions per visitor type in the dashboard's Who sees what grid — no code, no redeploy. See Generated versions below.

Code-native variants

The variants you declare in your app — the keys of the variants prop on <Adaptive> — live in your code. You do not create them in the dashboard first. The SDK reports the variant IDs when it requests an assignment, and the API registers them automatically the first time traffic hits the component after you deploy. They go live and start learning immediately. This is the path developers (and AI agents writing code) use.

No-code (managed) variants

Variants with no code — created from the dashboard (Project → Components → Add variant), via <AdaptiveText>, or through the MCP create_variant tool. Their content is stored on the SentientUI API and rendered by the SDK without a redeploy, which is what lets you iterate copy without shipping code. Managed variants start as a draft and must be activated from the dashboard before traffic is assigned to them.

Rule of thumb: to let SentientUI write versions for you, wrap the section in <Adaptive> with no variants and deploy. If you are writing the variant in code, add it to the variants map (and to the AdaptiveRoot components list for SSR) and deploy — it registers itself. Only use Add variant in the dashboard or MCP create_variant for no-code variants whose content lives in SentientUI.

<Adaptive> component

The main building block. Wrap any piece of UI you want to adapt. There are two ways to fill it: leave out variants and let SentientUI write versions for each visitor type, or write the variants yourself in code. Either way the SDK picks what each visitor sees, tracks impressions automatically, and records a score when the goal fires.

Generated versions — start here

Wrap a section and give it an id. Your children are the original. SentientUI may replace them with a version written for the visitor's type — replacement copy, or a small designed layout built from a validated set of building blocks (headings, text, buttons, badges, images, and forms), styled from your site's own colors and corner radius. Versions are created and reviewed in the dashboard's Who sees what grid — no redeploy.

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

<Adaptive id="hero-cta" goal="signup_click">
  <a className="btn" href="/signup">Start free trial</a>
</Adaptive>

The children render untouched for control/holdout traffic, visitors whose type isn't known yet, visitor types with no version, and every error path — the worst case is always your original renders, never a broken or empty section.

Props — generated versions

idstring
Unique identifier for this section within your project. Must start with a letter or digit, then letters, digits, _ or -, up to 128 characters.
childrenReactNode
Your original content. Serves as-is whenever no version applies — holdout, unknown visitor types, no version yet, or any error.
goalstring | GoalConfig (optional)
Optional goal attached to the container (click, scroll depth, composite — see Goal types below). Form versions fire their own submit goal regardless.
onFormSubmit(values: Record<string, string>) => void (optional)
Receives the field values when a form version submits. Without it, this section is never offered forms, and a form version that somehow arrives is refused whole — the children render instead. Values go only to your handler; SentientUI never stores them.
classNamestring (optional)
Class for the wrapping <div>.
reportBaselineTextboolean (default: true)
Sends the region's rendered text on first registration so generated versions are grounded in what they replace. Set false for personalized or account content. See below.

Forms — your goal fires, your handler gets the values

A version may contain one small form: at most 5 fields, every field labeled, input types limited to text, email, number, and tel — never password, file, or hidden inputs. Submitting fires one of your project's own goals (a form is only valid if its submit goal resolves to a real goal you defined), then calls your onFormSubmit with the values. SentientUI credits the conversion; the field values never reach SentientUI.

<Adaptive
  id="hero-cta"
  onFormSubmit={(values) => {
    // Your handler, your data — SentientUI never stores form values.
    createLead(values);
  }}
>
  <a className="btn" href="/signup">Start free trial</a>
</Adaptive>

Works in any React app — no server preload

Wrap the section and deploy; that is the whole integration. It works as-is under <AdaptiveRoot> (Next.js App Router) or <AdaptiveProvider> (everything else). When generated <Adaptive>s mount, the SDK asks for exactly those in one request and swaps in the version this visitor should see. Only sections actually on the page are decided, so one on another page never counts this visit against its versions. On a first visit your original shows briefly before a version replaces it; later visits start from the last version they were shown. In keyless local mode generated versions don't serve — your original renders.

Sections register themselves

Mount a generated <Adaptive> in a keyed app and deploy — that's the whole registration. If the server has no definition for the id, the SDK reports it once (batched, deduped) and it appears in the dashboard as a draft: a new column in Who sees what showing “Your original” for every visitor type, ready to generate into. Nothing serves and nothing on your page changes until the first version is created — creating it is what publishes the section.

The first report also carries the region's rendered text (whitespace-collapsed, capped at 400 characters, recorded only once) so generated versions are grounded in what they replace. Only the wrapped region is read — never the page around it. For a section wrapping personalized or account content, pass reportBaselineText={false} to register the id without sending any text.

Code variants

When you want to write the alternatives yourself, pass them as variants. The first key is the control, and goal is required.

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 — code variants

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.). The first key is the control. You can have two or more variants. The optimizer 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.
funnelstring (optional)
Stable id of the funnel this component serves (e.g. checkout), shown on the dashboard's Funnels tab. With a weighted_composite goal it also declares the funnel's ordered steps, and the optimizer trains this component on journey progress: small credit for intermediate steps, full credit at completion.
microSignalGoalsPartial<Record<MicroSignalType, string | { name; weight?; stepIndex? }>> (optional)
When a passive micro-signal fires on this component, also record a named goal — e.g. map rage_click to confused_by_hero.
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 first 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.
agentDataByVariantRecord<string, unknown> (optional)
Machine-readable content per variant for AI agents — only the assigned variant's entry is stored. Read once at mount, so pass the final value on first render. See Agent API.
agentDataunknown (deprecated)
Stored as-is for the assigned variant. Prefer agentDataByVariant.
Pass either variants or children, not both — if you do, variants wins and the children are ignored (with a warning in development). Generated mode was formerly <AdaptiveSlot>, which still works.

<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"
    goal="signup_click"
  />
</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. Pass goal (a name or a goal config, exactly like <Adaptive>) so the optimizer can score the wording.

Without a goal, <AdaptiveText> tracks impressions only — the copy is served and logged but never learns. 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.

Who sees what — versions per visitor type, no code

Project → Audiences → Who sees what is one grid that answers the question in its name. Rows are groups of visitors — plus a pinned New & undecided visitors row that always reads “Your original”, by design. Columns are the adaptive sections of your page: regions you wrapped in a generated <Adaptive> (no variants) or created from the dashboard. No square is ever blank — each one says what that group sees right now:

Each row also says where it came from, because a group you told us about and a group we are still guessing at are not the same thing and should never look the same:

RowWhat it means
EveryoneWhere most visitors sit, and where the work starts on day one. This row is live from your first visitor.
StarterA suggested group we ship in the box. You did not tell us it exists and we have not proved it does — it is a starting point you can rename, keep or delete.
DeclaredA group your own code tells us about — a signed-in role, a plan tier. We take your word for it; you know your users.
CandidateA pattern we think we can see forming in your traffic. It does not serve anyone and does not spend anything yet.
UnlockedA candidate your traffic has proved: this group genuinely responds differently, so it now gets its own versions.

The order matters. Nothing beyond Everyone and the groups you declare will serve anything until your own visitors justify it.

You seeWhat it means
Your originalNo version exists for this visitor type — they see your page as you built it.
Writing…A version is being written now, usually live in under a minute.
WaitingQueued behind the daily writing limit — resumes automatically, and says when.
Improving…A version is reliably beating your original, so an improved variation is being written to test against it. The current version keeps serving throughout.
PersonalizedThis visitor type sees their own version, with a plain verdict: “+12% vs your original”, “≈ same”, or “too early to tell”.

Click any square on the original to generate a version for that visitor type, or use Fill the rest to queue every visitor type still on the original. Fill the rest skips visitor types with negligible recent traffic rather than writing versions almost nobody would see.

Auto-fill — off by default

With auto-fill on, when a visitor of a type arrives at a section that has no version yet, SentientUI queues one automatically (the visitor's page never waits — they see your original). The toggle says exactly what it does: “It goes live without review.” That's the trade — leave it off to review every version yourself before it serves. Auto-fill is a paid-plan feature and is capped per day.

What the brand lock refuses

Every generated version is validated before it can serve — failed output is stored as rejected with the reason (“Couldn't create one — see why”), never patched into something servable. The rules:

Never free-form HTML. Versions are built only from the closed set of building blocks, with every style choice an enumerated token — no arbitrary markup, CSS, or scripts. No invented offers. A version may re-emphasize prices, claims, and deadlines that already exist on your page — it may never mint a discount, fake urgency, or invent social proof. Links stay on your own domain. Forms stay small and safe: at most 5 labeled fields, one form per version, no password/file/hidden inputs, and the submit goal must be a real goal of your project.

“Why this exists” and “how it's doing”

Every version stores a plain-language rationale — why it was written, and from what — and reports its performance against your original in plain language, from the same card where you can pause, pin, or regenerate it. A holdout slice of traffic always stays on your original, so “doing better than your original” is measured against your real page, not against a moving target.

The same grid is available to AI assistants through the MCP server — get_cell_matrix, get_cell_detail, and generate_cell read and operate exactly what the dashboard shows. See the MCP server section below.

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 useAdaptive 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 useAdaptive hook — you branch your own JSX and spread bind on the page's existing root element, so there is no extra wrapper, and impressions are still recorded for the variant actually served. (useAssignment is deprecated: it selects a variant but records nothing.)

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

function LandingPage() {
  const { value: Layout, bind } = useAdaptive('landing_layout', {
    variants: { control: StackedLayout, sidebar: SidebarLayout },  // first key = baseline
    // Scope the goal to the one element that means "converted". A string goal
    // here would listen for clicks on ANY button or link inside the page.
    goal: { type: 'click', selector: '[data-cta="signup"]' },
  });

  return (
    <main {...bind}>
      <Layout />  {/* renders <button data-cta="signup">…</button> somewhere */}
    </main>
  );
}
Be deliberate about the goal on a page-sized binding. A string goal like goal="signup" is shorthand for "a click on any button or link inside the bound element" — fine for a single CTA, wrong for a page, where a nav link or a cookie banner would count as the conversion. Pass a selector, or use scroll_depth / form_submit, so only the real outcome rewards the variant.

Conversions that finish outside the bound element — an account created after a server round trip, a thank-you page after a redirect — are fired explicitly, and still credited to the served layout: call the fireGoal that useAdaptive returns (or useAdaptiveGoal('landing_layout') from a child), or mark the destination with usePageGoal(name, { componentId: 'landing_layout' }). See the full-page recipe below.

Goal types

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

A string goal is the goal's name; an object config has none. goal="signup_click" reports under signup_click and can be promoted to a primary goal. goal={{ type: 'click' }} reports under the bare type click, so every inline click goal on the project collapses into one row named click and no goal definition is created for it. Prefer a named string; reach for an object when you need a selector, a threshold or a composite, and expect the generic name.

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 DETECTION to the string shorthand — but not identical
// reporting: this form has no name, so it is recorded as the goal
// "click" rather than one of your own. Use the string unless you
// need a selector.

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 score. Steps fire independently as they complete — the optimizer 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 optimizer 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 optimizer would take weeks to distinguish good variants from bad ones. Use composite (all-or-nothing) when you specifically want the optimizer to score 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 — you have two imperative options. Use useAdaptiveGoal(componentId) (or client.componentGoal()) when the conversion should be credited to a variant — it appears in the per-variant conversion funnel and resolves the served variant for you. Use client.goal() for session-level funnel steps that aren't tied to one component.

For the common case — a conversion that is simply reaching a page (pricing viewed, signup form reached, checkout opened) — use usePageGoal(name, opts?) rather than wiring useAdaptiveGoal into an effect yourself. It records the arrival once (an effect without a latch fires again on every remount, and twice in React's development double-invoke), and it holds the goal until the SDK is running, so a consent gate that opens after the page mounts doesn't lose it. Pass componentId to credit the variant that sent the visitor there; omit it for a session-level step. Prefer it over a click goal on the link: arrival survives the navigation and also counts visitors who arrived from the nav, search or a shared link.

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

export default function PricingPage() {
  usePageGoal('pricing_view', { componentId: 'hero_cta' });
  return <PricingTable />;
}
import { useAdaptiveGoal, useSentient } from '@sentientui/react';

function CheckoutSuccessPage() {
  const client = useSentient();
  // Resolves the served variant for 'hero_cta' — no variantId/projectId to pass.
  const fireHeroGoal = useAdaptiveGoal('hero_cta');

  useEffect(() => {
    // Bandit reward + per-variant CVR: credit the hero CTA variant this
    // session was served (render <Adaptive id="hero_cta"> earlier so it's assigned).
    fireHeroGoal('checkout_complete', { reward: 1, metadata: { plan: 'pro' } });

    // Optional: also record a session-level funnel step for cross-page Goals analytics.
    client?.goal('checkout_complete', { plan: 'pro' }, 1.0, 2);
  }, [client, fireHeroGoal]);

  return <ThankYouMessage />;
}

useAdaptiveGoal(componentId) returns a fireGoal(goalType, opts?) callback — the server-side equivalent is client.componentGoal(componentId, goalType, opts?). Both emit a goal_achieved event attributed to the served variant (the same signal <Adaptive goal> fires automatically), so you never hand-thread a variantId. client.goal(name, metadata?, weight?, stepIndex?) is separate: it POSTs a session-level goal to /v1/goals for funnel charts. 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.

Pass { once: true } as fireGoal's opts when the conversion is a state rather than a repeatable action — e.g. "reached step 3". It records at most once per mounted component, so an effect that re-runs (a remount, React's development double-invoke) never double-counts. Omit it for genuine repeat actions, where each occurrence — each "add to cart" click — is its own conversion.

Not sure what to track? On Starter and above, enable Observation Mode (management API) to pause optimizer 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 optimizer 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 { usePageGoal } from '@sentientui/react';

export default function PricingPage() {
  // Fires once on arrival, credited to the hero variant that sent them here.
  usePageGoal('viewed_pricing', {
    componentId: 'hero_cta',
    reward: 0.25,
    metadata: { source: 'nav' },
  });
  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 useAdaptive drives an entire page, declare only the in-page signal you trust and fire the real conversion at the exact business-logic boundary — signup handler, modal confirm, etc. Both are credited to the layout the visitor was served, never to whatever they happened to click.

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

function LandingPage() {
  const { value: Layout, bind, fireGoal } = useAdaptive('landing_layout', {
    variants: { control: ClassicLayout, sidebar_nav: SidebarLayout },
    // Partial credit for intent, scoped to the signup button only — clicks on
    // the nav, footer or pricing toggle reward nothing.
    goal: {
      type: 'weighted_composite',
      steps: [
        { goal: { type: 'click', selector: '[data-cta="signup"]' }, name: 'signup_click', weight: 0.2 },
      ],
    },
  });

  async function handleSignup(data: FormData) {
    const ok = await createAccount(data);
    if (!ok) return;
    // Full credit, only once the account exists. Attributed to the served
    // layout variant (no variantId to thread through) and recorded in the
    // Goals tab. A forced ?sentient_variant= preview records nothing.
    fireGoal('account_created');
  }

  return (
    <div {...bind}>
      <Layout onSignup={handleSignup} />
    </div>
  );
}

If signup ends on another route instead, drop the handler and put usePageGoal('account_created', { componentId: 'landing_layout' }) on the welcome page: the served variant is read from the assignment cache, so the credit survives the navigation. Credit from different goals on one variant adds up but is capped at one per visit, so a visitor who clicks and then signs up counts once, not 1.2 times.

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']}
          apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
        >
          {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 decisions and average score per layout order per audience. A higher average score means visitors in that audience converted better under that section sequence.

Note: layout personalization requires at least 4 complete visitor profiles before the first audience grouping 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 audience grouping.

Telling us what a section is

Sections are classified from their content, so most need nothing beyond a data-sentient-id. A band with little signal-bearing copy — an “About us” block, a contact form — reads as generic, and a generic section is ordered the same for every persona. Declare only the ones the classifier gets wrong with sectionTypes, keyed by data-sentient-id. Any section with a data-sentient-id can appear, reorderable or not. Also accepted by AdaptiveProvider.

<AdaptiveRoot
  sections={['hero', 'about', 'pricing', 'contact']}
  sectionTypes={{ about: 'trust', contact: 'cta' }}
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
>
  {children}
</AdaptiveRoot>

Accepted types: pricing, hero, social_proof, cta, features, faq, comparison, trust, navigation, generic.

AdaptiveRoot — sections and sectionTypes props

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.
sectionTypesPartial<Record<string, SemanticType>> (optional)
What each section is, keyed by data-sentient-id: { about: 'trust', contact: 'cta' }. Sections are classified from their content, so declare only the ones it gets wrong — a band with little signal-bearing copy reads generic and is ordered the same for every persona. Also accepted by AdaptiveProvider.

Personas

Layout ordering and component assignment are driven by personas. There are two ways a session gets one: your app declares it (the role you already know — admin, evaluator, member), or SentientUI infers it from the visitor's behavioral portrait over time. A declared persona is ground truth from your own code, so it always wins and is served at full confidence from the first request.

Declaring a persona

Each project has a persona vocabulary — the set of audience keys it optimizes and reports on, managed in the dashboard under Audiences → Personas. Add the roles your app already distinguishes, then pass the session's role at init:

// Core SDK — declared once at init, every decision inherits it
init({ apiKey: 'pk_…', persona: 'admin' });

// React — prop on the provider/root (SSR paths forward it too)
<AdaptiveProvider apiKey="pk_…" persona={user.role}>

// Snippet — string, or a function evaluated at init
window.sentient = {
  apiKey: 'pk_…',
  persona: () => document.body.dataset.role ?? null,
};

Keys are lowercase slugs (letters, digits, _, -, max 32 chars). Keep them low-cardinality role labels — never a user id or email. The vocabulary is capped at 12 personas because every persona divides each experiment cell's traffic: fewer personas learn faster.

A declared value that is not in the vocabulary is deliberately ignored — the session runs on the inferred path instead, and the dashboard surfaces the unrecognized value in Audiences → Personas so you can add it with one click. That means a typo never corrupts your data, and a persona you add starts working retroactively for new decisions without a code change.

The persona is fixed for the visit once declared (decisions are locked per visit — changing it mid-session is ignored). Renaming a persona in the dashboard keeps the old key working as an alias, so existing code and CSS hooks (html[data-sentient-persona=…]) stay intact.

Inferred personas

Sessions with no declared persona are clustered automatically from how visitors interact with your product. A new project starts with no personas at all — every visitor resolves to unknown, and Sentient tests and learns against all of your traffic pooled together from the first request. That is the honest starting point: we have not met your visitors yet, so we do not assert that they divide into groups.

A persona comes to exist one of two ways. You declare one — passing a role your own app already knows, like admin or trial — and it is ground truth from the first request, no evidence gate. Or one is discovered: clustering proposes a segment from behavioral portraits, and it only starts serving once it clears the interaction gate that shows it actually behaves differently.

Until 2026-09-13 new projects were seeded with four generic behavioral personas. They were removed: a taxonomy shipped in the box is a claim about your visitors made before seeing any of them, and on real traffic almost every session landed in unknown anyway.

An inferred 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. Declared personas skip this gate entirely.

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.

Discovered audiences

Beyond the default vocabulary, SentientUI proposes discovered audiences: a weekly job clusters sessions by where they actually spend attention (share of dwell per section type, with device and traffic-source context) and names the segments from that behavior — “pricing-first mobile visitors”, not personality adjectives. The number of segments comes from your data; a small site legitimately resolves to two.

Candidates appear read-only under Audiences → Personas and change nothing about serving. Each segment must first earn it by showing that a different version wins for that segment than for everyone else — not merely that it converts better or worse overall, which no amount of separate serving can act on. That is judged with the same multiplicity-corrected machinery as the variants page. Only when every segment passes can you explicitly promote the set to serving; promotion retires the previous vocabulary (keys you map keep resolving as aliases, so CSS hooks survive), and you can roll back at any time — nothing is deleted.

Personas are derived from behavioral signals tied to a pseudonymous session id (and optional identify() user id). Treat those identifiers as personal data where GDPR/ePrivacy apply — gate with consent. See the Privacy page for the inventory.

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 visitor profiles are more reliable — the optimizer has more signal per user, audience grouping 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 as provided (for cross-session linking) and is not shown in the dashboard visitor UI.

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 is deprecated and unused — nothing in the SDK or API reads it. The project's type is set in the dashboard. Existing code that passes it keeps working; new code can omit it.

useAssignment hook

Deprecated — use useAdaptive (Rung 2 in the adaptive ladder), which selects the variant and wires impressions and goals; useAssignment only selects and is kept for backward compatibility.

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}
      initialAssignments={pageProps.initialAssignments}
      ssrSessionId={pageProps.ssrSessionId}
    >
      <Component {...pageProps} />
    </AdaptiveProvider>
  );
}

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

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

  return { props: { initialAssignments: assignments, ssrSessionId: sessionId } };
}

Pass ssrSessionId too. Without it the browser starts a different session than the one the server assigned, so the preload silently does nothing and events attach to the wrong session.

The SDK writes a first-party cookie (_snt_uid_<key>, 365 days) and sends events to the API. If your product requires explicit consent before setting cookies or tracking behaviour, pass consentFrom (your consent platform) or use the consent prop.

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

<AdaptiveProvider
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY}
  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 render the first variant. 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.

Rather than flipping consent yourself, tell the SDK where your consent decision lives. It reads the source on mount, re-reads it whenever your event fires, and starts the moment it grants — no reload, and no glue code in your app. Keep whatever banner or CMP you already have.

<AdaptiveProvider
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
  consentFrom={{ cookie: 'cookie_consent', value: 'accepted', event: 'consent-decided' }}
>
  {children}
</AdaptiveProvider>

Nothing is requested and no cookie is written until the source grants. The event's payload is never trusted — the source is re-read each time it fires — so a “declined” decision correctly leaves the SDK gated, and any CMP's event works.

If you use one of these, name it. The SDK reads that platform's own API, listens to its own events (accept and withdraw), and — with <AdaptiveRoot> — reads its own cookie on the server, so a visitor who already consented is rendered server-side with no layout shift.

consentFrom="cookiebot"              // Statistics category
consentFrom="onetrust"               // Performance group (C0002)
consentFrom="cookieyes"              // Analytics category
consentFrom="tcf"                    // any IAB TCF v2.2 CMP (Usercentrics, Didomi, Quantcast…) — purposes 1, 5, 6 and 8
consentFrom="google-consent-mode"    // analytics_storage — any CMP that drives Consent Mode v2
consentFrom="shopify"                // Shopify's Customer Privacy API (snippet 0.32+ / react 0.37+; the snippet's default on Shopify)

// Choose a different category, group, purposes, region or vendor:
consentFrom={{ cmp: 'cookiebot', category: 'marketing' }}
consentFrom={{ cmp: 'onetrust', group: 'C0004' }}
consentFrom={{ cmp: 'tcf', purposes: [1, 8] }}      // only if your CMP never offers 5 and 6
consentFrom={{ cmp: 'tcf', vendorId: 1234 }}        // also require consent for a GVL vendor
consentFrom={{ cmp: 'google-consent-mode', region: 'ES' }}  // resolve region-scoped defaults

TCF needs purposes 1, 5, 6 and 8 (storage, a content profile, personalised content, measurement; 8 may rest on legitimate interest). A CMP that doesn't offer 5 and 6 leaves every visitor gated. Configure those purposes in your CMP, or pass the ones you do collect as purposes. Google Consent Mode: without region, a region-scoped denial (region: ['DE', …]) keeps every visitor gated until an update — pass the visitor's country from your CDN to resolve it exactly.

A refusal deletes; a pending answer only waits. When the platform records a “no”, the SDK deletes the visitor ID and everything it stored, even if the no was given on a page without SentientUI. While the banner is loading, showing, or reopened by a visitor who already said yes, tracking pauses and the visitor keeps their ID. For your own cookie ({ cookie, value }), any other value counts as a refusal. With TCF only a refusal of purpose 1 (storage) deletes; a missing profiling purpose, or any answer under purposeOneTreatment, only keeps tracking off.

The same option works in the no-code snippet: window.sentient = { apiKey: '…', consentFrom: 'cookiebot' }. For any other platform, pass a predicate and its event (listened for on both window and document):

consentFrom={{
  check: () => window.myCmp?.analytics === true,
  event: 'mycmp:consent-changed',
  // Optional: true once the visitor actually said no (not merely "not answered"),
  // so a refusal also deletes what SentientUI stored. Without it, false only pauses.
  refused: () => window.myCmp?.answered === true && window.myCmp.analytics === false,
}}

The same watcher can gate your other consent-bound scripts (an ad pixel, a chat widget) on the same platform, so there is one source of truth. It is exported from @sentientui/react and available on the snippet's page API:

// React / Next.js
import { consentWatcher } from '@sentientui/react';
// No-code snippet
const consentWatcher = window.SentientSnippet.consentWatcher;

const marketing = consentWatcher({ cmp: 'cookiebot', category: 'marketing' });
const apply = () => { if (marketing.read() === true) loadPixel(); };
apply();
const stop = marketing.subscribe(apply); // fires on accept AND withdraw

<AdaptiveRoot> is a Server Component, so its consent prop cannot change without a server round trip. Do not hide it behind a conditional render and call router.refresh() on accept: the refresh has to complete before tracking starts, and visitors who accept and leave within a second or two are never counted. Render it unconditionally and pass consentFrom — that is the whole integration:

// app/page.tsx — Server Component
<AdaptiveRoot
  apiKey={process.env.NEXT_PUBLIC_SENTIENT_API_KEY!}
  consentFrom="cookiebot" // or a cookie: { cookie: 'cookie_consent', value: 'accepted', event: 'consent-decided' }
>
  {children}
</AdaptiveRoot>

<AdaptiveRoot> is a Server Component, so it reads the consent cookie — yours, or the platform's own (Cookiebot, OneTrust, CookieYes, the TCF string) — from the request itself; you do not need to call cookies(). Google Consent Mode has no cookie, so with it the first paint is always gated and the browser decides. A visitor who has already consented therefore gets SSR variant assignment with zero layout shift on first paint, while everyone else is gated: no /v1/decide call and no session is minted until they accept in the browser.

<AdaptiveRoot> accepts only the cookie form: a check() function cannot cross the server→client boundary and would throw at render. For a JS-API CMP predicate, render <AdaptiveProvider> from a client component instead. Pass consent explicitly to override the cookie read entirely — the escape hatch for apps that track consent somewhere else.

The _snt_uid_<key> cookie is a first-party visitor identifier (random UUID). Under GDPR / UK GDPR, online identifiers linked to behavioral history can still be personal data — gate the SDK with consent where required. See the Privacy page for the full data inventory, retention, and deletion endpoints.

Do Not Track

The SDK honours the browser's Do Not Track (DNT) signal automatically — no integration required. When a visitor has DNT enabled, the SDK sets no cookies and sends no tracking data, behaving exactly as consent={false}. If preConsentBehavior is 'statistical_winner', it still serves the currently favoured variant (at least 100 visits; the baseline until then) via the read-only GET /v1/winner endpoint, which stores nothing about the visitor.

DNT is treated as a global opt-out: it overrides consent={true}, and grantConsent() will not re-enable tracking while it is active. DNT is not a substitute for your consent gate — most browsers no longer send it, so you still need the consent prop for EU / California visitors. To make your own consent management authoritative over the browser signal, set respectDoNotTrack={false}.

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}
  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 optimizer — 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 optimizer entirely — no events are recorded and the variant weights are not affected.

Testing

Use @sentientui/react/testing to make adaptive UI deterministic in your own tests. By default it serves the control variant and default layout and sends no events, so existing tests keep passing; pass a scenario to force specifics.

Component tests (Jest / Vitest / RTL)

renderWithSentient renders under a test provider that never initialises a client — every <Adaptive> renders its control variant with zero network.

import { renderWithSentient } from '@sentientui/react/testing/react';
import { screen } from '@testing-library/react';

test('force a variant + layout', () => {
  renderWithSentient(<MyPage />, {
    variants: { hero_cta: 'accent' },
    layout: ['pricing', 'hero'],
  });
  expect(screen.getByText('…')).toBeInTheDocument();
});

Mock the API + assert goals

setupSentientServer is an MSW-backed mock backend. Control API responses (including injected errors), and assert which goals fired.

import { setupSentientServer } from '@sentientui/react/testing/node';
import { getSentientEvents, hasFiredGoal } from '@sentientui/react/testing';

const s = setupSentientServer();
afterAll(() => s.server.close());

test('signup goal fires', async () => {
  s.use({ variants: { hero_cta: 'accent' }, api: { '/v1/assign': 'error' } });
  // …render + trigger the interaction…
  expect(hasFiredGoal(getSentientEvents(), 'signup')).toBe(true);
});

E2E (Playwright / Cypress)

Use mockSentient to force variants/layout and stub the API in one call — it also captures posted events so you can assert goals.

import { mockSentient } from '@sentientui/react/testing';

test('checkout page, admin persona', async ({ page }) => {
  const s = await mockSentient(page, {
    variants: { hero_cta: 'accent' },
    persona: 'admin',
  });
  await page.goto('/');
  // …drive the flow…
  expect(s.events().some((e) => e.goalType === 'signup')).toBe(true);
});

Cypress is the same shape via mockSentientCypress(cy, scenario) in a beforeEach. Prefer mockSentient in CI — it intercepts the network, so it writes nothing at all.

The URL param (see Local overrides above) is handy for a quick local pin with no import, but it only forces the variant — a live client still creates a session and fires events:

await page.goto('/?sentient_variant=hero_cta:control&sentient_variant=pricing:monthly');
Automation traffic (E2E drivers set navigator.webdriver) is excluded from the optimizer and from your billing quota — so the URL param won't skew results or exhaust your limit. It does still create automation session rows; mockSentient avoids even those. Never point a non-browser live client at the real API.

Agent API

SentientUI serves structured variant content to AI agents — LLMs and orchestration frameworks that need to read the layout currently favoured 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 currently favoured variants for that visitor, each with itsevidence_state — favoured is not proven; check it before calling anything a winner. Pass user_id instead if you identified the visitor with client.identify().
personastring | null
The visitor's persona key from your project's vocabulary (e.g. admin), 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 currently favoured variant.
The agent layout endpoint uses server keys (sk_), not public keys (pk_). Generate one in Project settings → API key → Server key (Starter plan or higher). 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 via the Model Context Protocol — in IDEs (Claude Code, Cursor, VS Code, Codex) and in web/app assistants (claude.ai, ChatGPT). Ask “Are any variants paused?” or “What changed in conversion rate this week?” without leaving the assistant.

Connect by URL (recommended). No key to paste — you sign in with your SentientUI account. This is the only way to connect web/app assistants like claude.ai and ChatGPT.

https://api.sentient-ui.com/mcp

In claude.ai or ChatGPT, add a custom connector with that URL and sign in when prompted. In Claude Code: claude mcp add --transport http sentientui https://api.sentient-ui.com/mcp. For stdio-only clients such as Codex: npx mcp-remote https://api.sentient-ui.com/mcp.

Or run locally with a server key — for offline or programmatic use. The server key is the same sk_ key as the agent API — generate one in Project → Project settings → API key → Server key (Starter+). Restart your IDE after saving.

// ~/.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" }
    }
  }
}

Available tools: list_projects, create_project, 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, get_integration_guide, get_test_brief, get_variant_brief, get_cell_matrix, get_cell_detail, generate_cell.

The last three mirror the Who sees what grid: read which visitor type sees which version (with traffic share and auto-fill status), read one version's story — its stored rationale and a plain-language verdict vs your original — and queue generation of a new version, exactly as the dashboard would.

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 AI optimizer. The optimizer 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 optimizer 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 decisions toward a configurable target (default 500). Below that threshold the algorithm is still actively exploring. Above it, reliability is high enough to consider promoting a winner manually or letting the optimizer 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.