Skip to content

Core Concepts

Presage is built around a small set of composable primitives. Understanding these concepts will help you design effective adaptive interfaces.

The UserContext is the single source of truth for who the user is and how they behave. It combines three dimensions:

interface UserContext {
traits: UserTraits // Static data you set (role, plan, company)
signals: BehavioralSignals // Auto-computed from tracked events
maturity: Maturity // Automatic segment: 'new' | 'onboarding' | 'active' | 'power' | 'dormant'
}

Traits are static attributes you set explicitly — typically from your auth system or user profile.

interface UserTraits {
userId?: string
role?: string
plan?: string
signupDate?: string
company?: string
companySize?: number
locale?: string
[key: string]: unknown // Extensible with any custom field
}

Set traits with client.identify() or client.updateTraits().

Signals are computed automatically from tracked events. You never set them directly.

interface BehavioralSignals {
sessionCount: number
totalEvents: number
featureUsage: Record<string, number>
lastSeenAt: string
firstSeenAt: string
currentSessionDuration: number
daysSinceSignup: number
clickMap: Record<string, number>
customSignals: Record<string, number>
}

For example, after tracking client.track('feature_used', { featureId: 'export' }) three times, signals.featureUsage.export will equal 3.

Maturity is a computed segment derived from behavioral signals. It classifies users into one of five levels:

SegmentMeaning
newFew sessions (default: 3 or fewer)
onboardingStill learning (default: 4-10 sessions)
activeRegular user beyond onboarding
powerUses many features (default: 5+ distinct features)
dormantInactive for too long (default: 14+ days)

Thresholds are configurable via MaturityConfig.

An adaptation point is a place in your UI where different variants can be shown. It is defined by:

interface AdaptationPoint {
id: string // Unique identifier (e.g. 'onboarding')
variants: readonly string[] // Available variant IDs
defaultVariant: string // Fallback when no rule matches
strategy: Strategy // How to select (currently: { type: 'rules' })
}

In React, the <Adaptive> component creates an adaptation point implicitly from its props and children.

A rule maps a set of conditions to an action. When the conditions match the current UserContext, the action determines what happens at the adaptation point.

interface AdaptationRule {
id: string // Unique identifier
adaptationId: string // Which adaptation point this rule targets
priority: number // Higher priority rules are evaluated first
conditions: ConditionGroup // Boolean logic tree
action: AdaptationAction // What to do when conditions match
}

A Condition checks a single field in the user context:

interface Condition {
field: string // Dot-path: 'traits.role', 'signals.sessionCount', 'maturity'
operator: ConditionOperator // One of 14 operators
value: unknown // Value to compare against
}

Groups combine conditions with boolean logic:

interface ConditionGroup {
all?: (Condition | ConditionGroup)[] // AND — all must match
any?: (Condition | ConditionGroup)[] // OR — at least one must match
not?: Condition | ConditionGroup // NOT — inverts the result
}

Groups can be nested arbitrarily deep.

An action describes what happens when a rule matches:

type AdaptationAction =
| { type: 'show'; variantId: string } // Show a specific variant
| { type: 'hide' } // Hide the adaptation point entirely
| { type: 'reorder'; order: string[] } // Reorder items (e.g. navigation)
| { type: 'modify'; props: Record<string, unknown> } // Modify component props

A strategy determines how variants are selected. Currently, only rule-based evaluation is available:

type Strategy = { type: 'rules' }

Coming in v0.2: Multi-armed bandit strategies that automatically optimize variant selection based on conversion metrics.

The tracker records user events and feeds the signal computation pipeline.

// Track events with optional properties
client.track('feature_used', { featureId: 'dashboard-export' })
client.track('click', { elementId: 'nav-settings' })
client.track('custom_signal', { signalId: 'engagement', value: 5 })

Special event names trigger specific signal computations:

  • feature_used with featureId — increments signals.featureUsage[featureId]
  • click with elementId — increments signals.clickMap[elementId]
  • custom_signal with signalId and value — increments signals.customSignals[signalId]

You can also plug in external analytics adapters (Segment, PostHog, etc.) to forward events.

Persistence keeps traits and signals across page reloads. Two built-in drivers are provided:

  • createLocalStorageDriver(prefix) — Stores data in localStorage under a namespaced key
  • createMemoryDriver() — In-memory storage for SSR and testing

When a persistence driver is configured, the client synchronously reads cached data on initialization — this prevents a flash of default content (anti-FOUC).

When a component requests a variant, here is what happens:

  1. The component declares an AdaptationPoint (id, variants, default)
  2. The rules engine filters rules by adaptationId
  3. Rules are evaluated in priority order (highest first)
  4. The first rule whose conditions match returns its action
  5. The action determines which variant renders
  6. If no rule matches, the defaultVariant is used
  7. An presage:impression event is automatically tracked

This entire flow is synchronous and happens in a single render cycle.