Supported on: Android SDK 1.0.33+
You can A/B test this onboarding flow with no extra code — create an experiment on it in the Console and the SDK serves the assigned variant automatically. See Servable Surface Experiments.
The AppDNA onboarding module lets you present server-driven onboarding flows that are configured in the AppDNA Console. Flows are delivered to the SDK via the remote config bundle, so you can update onboarding experiences without shipping app updates.
Present an Onboarding Flow
Present a specific onboarding flow by ID:
import ai.appdna.sdk.AppDNA
val presented = AppDNA.presentOnboarding(
activity = this,
flowId = "main_flow",
listener = onboardingDelegate
)
if (!presented) {
Log.w("Onboarding", "Flow config not available — check Console or network")
}
The method returns false if the flow configuration is not available (e.g., config has not loaded yet or the flow ID is invalid).
If flowId is null, the SDK presents the currently active flow as configured in remote config. This is useful when you want the Console to control which flow is shown.
Module Access
Access the onboarding module directly:
val onboarding = AppDNA.onboarding
Module Methods
| Method | Signature | Description |
|---|
present | present(activity: Activity, flowId: String?, context: OnboardingContext?): Boolean | Present an onboarding flow. The delegate is whatever you registered via setDelegate(...). |
setDelegate | setDelegate(delegate: AppDNAOnboardingDelegate?) | Set a delegate for flow callbacks |
The context argument is accepted for API symmetry with iOS but is not yet propagated to the underlying flow on Android — pass context through userProperties on identify(...) if you need it available in the running flow.
OnboardingContext
Pass additional context when presenting a flow:
import ai.appdna.sdk.onboarding.OnboardingContext
val context = OnboardingContext(
source = "app_launch",
campaign = "winter_2025",
referrer = "social_ad",
userProperties = mapOf("locale" to "en_US"),
experimentOverrides = mapOf("onboarding_variant" to "b")
)
AppDNA.onboarding.present(
activity = this,
flowId = "main_flow",
context = context
)
| Property | Type | Description |
|---|
source | String? | Where the flow was triggered from |
campaign | String? | Attribution campaign identifier |
referrer | String? | Referral source |
userProperties | Map<String, Any>? | Additional user properties for personalization |
experimentOverrides | Map<String, String>? | Override experiment variant assignments for testing |
Paywalls inside onboarding flows
Steps with outcome: present_paywall open a paywall and route the user based on the result (purchase / fail / dismiss → next step). The host’s AppDNAPaywallDelegate (registered via AppDNA.paywall.setDelegate(...)) fires for these embedded paywalls identically to standalone presentations — purchase, restore, action, promo-code, and post-purchase callbacks all reach the host. See the paywall docs for the full delegate surface.
AppDNAOnboardingDelegate
Implement the delegate interface to respond to onboarding flow events:
interface AppDNAOnboardingDelegate {
fun onOnboardingStarted(flowId: String)
fun onOnboardingStepChanged(flowId: String, stepId: String, stepIndex: Int, totalSteps: Int)
fun onOnboardingCompleted(flowId: String, responses: Map<String, Any>)
fun onOnboardingDismissed(flowId: String, atStep: Int)
// Async hooks (optional — default implementations provided)
suspend fun onBeforeStepAdvance(
flowId: String, fromStepId: String, stepIndex: Int,
stepType: String, responses: Map<String, Any>, stepData: Map<String, Any>?
): StepAdvanceResult = StepAdvanceResult.Proceed
suspend fun onBeforeStepRender(
flowId: String, stepId: String, stepIndex: Int,
stepType: String, responses: Map<String, Any>
): StepConfigOverride? = null
}
Example Implementation
class OnboardingHandler : AppDNAOnboardingDelegate {
override fun onOnboardingStarted(flowId: String) {
Log.d("Onboarding", "Started: $flowId")
}
override fun onOnboardingStepChanged(
flowId: String,
stepId: String,
stepIndex: Int,
totalSteps: Int
) {
Log.d("Onboarding", "Step ${stepIndex + 1}/$totalSteps: $stepId")
// Update progress indicator
}
override fun onOnboardingCompleted(flowId: String, responses: Map<String, Any>) {
Log.d("Onboarding", "Completed: $flowId")
Log.d("Onboarding", "Responses: $responses")
// Navigate to main app screen
}
override fun onOnboardingDismissed(flowId: String, atStep: Int) {
Log.d("Onboarding", "Dismissed at step $atStep")
// Handle early exit — maybe show again later
}
}
Step Types
Onboarding flows support the following step types, configured in the Console:
| Step Type | Description |
|---|
welcome | Welcome screen with title, subtitle, and hero image |
question | Single-select or multi-select question for user input |
value_prop | Value proposition screen highlighting a key feature or benefit |
form | Structured form with multiple native input fields |
interactive_chat | AI-powered conversational step |
info | Informational screen rendered through the custom-step pipeline |
permission | Push notification or tracking permission request |
custom | Custom Composable content rendered via a template |
Step types and their content are defined entirely in the Console. The SDK renders them automatically based on the flow configuration. You do not need to build UI for individual step types.
Selection Modes
Question steps support two selection modes:
| Mode | Description |
|---|
SINGLE | User selects exactly one option |
MULTI | User can select multiple options |
Content Blocks
Each onboarding step is composed of content blocks that control the visual layout. Blocks are configured in the Console and rendered natively using Jetpack Compose. The block string the Console emits is the same string the SDK switches on. The following block types are available:
Layout primitives
| Block Type | Description |
|---|
heading | Primary heading text |
text | Body / subtitle text |
rich_text | Markdown-style rich text with inline formatting |
list | Bulleted or numbered list |
badge | Pill-shaped label |
icon | Single icon (Lucide / Material / SF / emoji) |
image | Static image with sizing and corner radius |
lottie | Lottie animation (JSON or dotLottie) |
rive | Rive state-machine animation |
video | Inline video (MP4, HLS) with autoplay and loop options |
divider | Horizontal separator |
spacer | Configurable vertical spacing |
stack | Vertical container that groups child blocks |
row | Horizontal container that arranges child blocks side by side |
star_background | Animated starfield or particle background effect |
custom_view | Host-app-provided Composable view (see Custom View Registration) |
Interactive blocks
| Block Type | Description |
|---|
button | Tappable button with configurable action |
social_login | Social sign-in buttons (Google, etc.) |
wheel_picker | Scrollable wheel-style picker for value selection |
pulsing_avatar | Animated avatar with a pulsing ring effect |
timeline | Vertical timeline with labeled milestones |
animated_loading | Skeleton or spinner loading animation between steps |
countdown_timer | Countdown timer with configurable duration and expiry action |
rating | Star or emoji rating selector |
progress_bar | Horizontal progress bar with percentage or label |
circular_gauge | Circular progress indicator with value label |
date_wheel_picker | Native date wheel picker (day/month/year columns) |
page_indicator | Dot or bar indicator showing current step progress |
pricing_card | Product pricing card with plan details and CTA |
Form inputs are individual blocks (not a single form block). Each input type emits its current value through the step’s inputValues map and is included in the step responses on completion.
| Block Type | Renders |
|---|
input_text | Single-line text input |
input_textarea | Multi-line text input |
input_number | Numeric input |
input_email | Email input with validation |
input_phone | Phone-number input |
input_url | URL input |
input_password | Secure text with visibility toggle |
input_date | Date picker |
input_time | Time picker |
input_datetime | Combined date + time picker |
input_select | Dropdown / stacked / grid picker |
input_slider | Numeric slider |
input_range_slider | Dual-handle range slider |
input_toggle | On/off switch |
input_stepper | Increment/decrement counter |
input_segmented | Segmented control |
input_rating | Star rating input |
input_chips | Tag-style multi-select chips |
input_color | Color picker (HSV) or preset swatches |
input_image_picker | Photo picker (gallery + crop) |
input_signature | Freehand signature drawing pad |
input_location | Autocomplete location search |
Blocks are configured entirely in the Console. The SDK renders them automatically — no code is needed unless you register custom views.
button and social_login blocks emit a structured payload to your AppDNAOnboardingDelegate.onBeforeStepAdvance hook when tapped. The action value on the button (configured in the Console) determines what shape the payload takes and lets you route to the right backend call (sign in, register, request OTP, etc.).
If the user has not completed required form fields on the step, the SDK shows an inline validation toast and does not emit the action — same gate as the standard next button.
Flow control
| Action | What the SDK does |
|---|
next | Advances to the next step. Payload includes all collected inputValues + toggle_* values. |
skip | Skips the step (subject to skip_enabled config). |
link | Opens action_value (a URL) in a Custom Tab. Step does not advance. |
permission | Reserved for runtime permission requests (camera, notifications, …). Currently advances as a safe fallback. |
Authentication entry
When the user taps a button with one of these actions, the SDK calls your onBeforeStepAdvance handler with the payload below. Show a “Signing in…” spinner by returning StepAdvanceResult.Block(message) while async auth runs, then return Proceed / ProceedWithData when the backend responds.
If you do not implement AppDNAOnboardingDelegate, the SDK stays on the step and logs a warning rather than advancing past credential collection. Auth actions require a host to perform the actual side effect (sign in, register, send OTP, …) — silently advancing would leak credentials into the response bundle without ever calling your backend.
| Action | Typical button label | Payload to your handler |
|---|
login | ”Sign In” | {action: "login", email, password} |
register | ”Sign Up” / “Create Account” | {action: "register", email, password, ...optional fields} |
social_login | ”Continue with Google” / etc. | {action: "social_login", provider, ...inputs} |
email_login | ”Continue with Email” (the email provider button inside a social_login block) | {action: "email_login", email, password, ...inputs} — added in v1.0.32. Email button still emits social_login in parallel one final release for back-compat; will be removed in v1.1.0. Migrate to email_login for clean handler routing. |
reset_password | ”Forgot password?” | {action: "reset_password", email} |
magic_link | ”Email me a sign-in link” | {action: "magic_link", email} |
request_otp | ”Send code” | {action: "request_otp", [channel], [recipient]} — see channel resolution below |
verify_otp | ”Verify” | {action: "verify_otp", otp_code, [channel], [recipient]} |
verify_email | ”Confirm email” | {action: "verify_email", verification_code} |
resend_verification | ”Resend code” | {action: "resend_verification"} |
enable_biometric | ”Enable Fingerprint” | {action: "enable_biometric"} — your handler calls BiometricPrompt.authenticate(...) |
Account lifecycle
| Action | Typical button label | Payload to your handler |
|---|
logout | ”Sign out” | {action: "logout"} |
change_password | ”Change password” | {action: "change_password", current_password, new_password} |
set_new_password | ”Set new password” | {action: "set_new_password", new_password, [reset_token]} (post-reset / first-time setup) |
delete_account | ”Delete account” | {action: "delete_account", [confirmation_text]} |
update_profile | ”Save” | {action: "update_profile", ...all step inputs} |
request_otp channel resolution
request_otp and verify_otp include a channel field so your handler knows which delivery method to call back on. The SDK resolves it in this order:
- Explicit — set the button’s
action_value to "sms", "email", "whatsapp", or "voice" in the Console (case-insensitive).
- Auto-detect — if the step has exactly one phone-typed input (
input_phone) → channel: "sms"; exactly one email-typed input (input_email) → channel: "email".
- Omitted — if the step has both an email AND a phone input (or neither), the
channel key is absent from the payload. Check with stepData?["channel"] as? String and fail explicitly if null rather than guessing. Set action_value on the button to disambiguate.
The matching input field’s value is included as recipient so you don’t have to look it up from the step responses.
Handling auth actions
class OnboardingHandler(
private val activity: FragmentActivity,
private val authClient: AuthClient,
private val otpClient: OtpClient,
) : AppDNAOnboardingDelegate {
override suspend fun onBeforeStepAdvance(
flowId: String,
fromStepId: String,
stepIndex: Int,
stepType: String,
responses: Map<String, Any>,
stepData: Map<String, Any>?,
): StepAdvanceResult {
val action = stepData?.get("action") as? String ?: return StepAdvanceResult.Proceed
return when (action) {
"login" -> {
val email = stepData["email"] as? String ?: ""
val password = stepData["password"] as? String ?: ""
try {
val user = authClient.signIn(email, password)
StepAdvanceResult.ProceedWithData(mapOf("user_id" to user.id))
} catch (e: Exception) {
StepAdvanceResult.Block("Invalid email or password.")
}
}
"register" -> {
val email = stepData["email"] as? String ?: ""
val password = stepData["password"] as? String ?: ""
try {
val user = authClient.register(email, password)
StepAdvanceResult.ProceedWithData(mapOf("user_id" to user.id))
} catch (e: Exception) {
StepAdvanceResult.Block("Couldn't create account — try a different email.")
}
}
"reset_password" -> {
val email = stepData["email"] as? String ?: ""
runCatching { authClient.requestPasswordReset(email) }
// Stay on step; the user is told the email was sent.
StepAdvanceResult.Stay("If an account exists for $email, we've emailed a reset link.")
}
"request_otp" -> {
// Fail explicitly when the SDK couldn't resolve the channel
// (step has both phone and email inputs, or neither). Set the
// button's action_value in the Console to disambiguate.
val channel = stepData["channel"] as? String
?: return StepAdvanceResult.Block(
"Couldn't determine OTP delivery channel — please contact support."
)
val recipient = stepData["recipient"] as? String
?: return StepAdvanceResult.Block("Missing recipient.")
try {
otpClient.send(channel, recipient)
StepAdvanceResult.Proceed
} catch (e: Exception) {
StepAdvanceResult.Block("Couldn't send code — please retry.")
}
}
"verify_otp" -> {
val code = stepData["otp_code"] as? String
val recipient = stepData["recipient"] as? String
if (code == null || recipient == null) {
return StepAdvanceResult.Block("Missing code or recipient.")
}
try {
otpClient.verify(code, recipient)
StepAdvanceResult.Proceed
} catch (e: Exception) {
StepAdvanceResult.Block("Code didn't match — try again.")
}
}
"enable_biometric" -> {
val biometricManager = androidx.biometric.BiometricManager.from(activity)
val canAuthenticate = biometricManager.canAuthenticate(
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
)
val success = canAuthenticate ==
androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS &&
authClient.promptBiometric(activity)
StepAdvanceResult.ProceedWithData(mapOf("biometric_enabled" to success))
}
else -> StepAdvanceResult.Proceed
}
}
}
Existing next, skip, link, permission, and social_login button actions are unchanged. Host apps that already implement AppDNAOnboardingDelegate keep working without changes — the default onBeforeStepAdvance returns Proceed, so flows that don’t switch on stepData?["action"] advance normally. Host apps that have NO delegate set will see the no-delegate warning above when an auth action fires; for those flows, either implement the delegate or keep using next as the button action.
Custom View Registration
Register your own Composables to be rendered inside onboarding steps wherever a custom_view block appears. The factory receives the block’s config map so you can parameterize the view from the Console:
AppDNA.registerCustomView("my_view") { config ->
MyComposableView(config)
}
// Register before presenting the onboarding flow
AppDNA.registerCustomView("terms_acceptance") { config ->
val termsUrl = config["terms_url"] as? String
TermsAcceptanceView(
termsUrl = termsUrl,
onAccept = { accepted -> /* Handle acceptance */ },
)
}
The id must match the custom view identifier configured in the Console for the custom_view block.
Dynamic Bindings
You can pre-fill form fields and override copy at render time via onBeforeStepRender by returning a StepConfigOverride:
override suspend fun onBeforeStepRender(
flowId: String, stepId: String, stepIndex: Int,
stepType: String, responses: Map<String, Any>,
): StepConfigOverride? {
return StepConfigOverride(
fieldDefaults = mapOf(
"email" to currentUser.email,
"name" to currentUser.displayName,
),
title = "Welcome back, ${currentUser.firstName}!",
subtitle = "Pick up where you left off",
ctaText = "Continue",
)
}
Available fields on StepConfigOverride: fieldDefaults, title, subtitle, ctaText, layoutOverrides. The SDK merges these into the step config before rendering.
Block Styling
Every content block supports a block_style design token that controls appearance properties such as padding, margin, background color, corner radius, border, shadow, and opacity. Block styles are configured in the Console and applied automatically by the SDK.
Visibility Conditions
Blocks can be shown or hidden based on user responses, bindings, or device attributes. Visibility conditions are configured per-block in the Console using rules like answer_equals, binding_not_empty, platform_is, and locale_matches. The SDK evaluates conditions client-side before rendering each block.
Entrance Animations
Each block supports an entrance animation that plays when the block first appears. Animations are configured per-block in the Console. Supported animation types include fade, slide_up, slide_down, slide_left, slide_right, scale, flip, and bounce. You can configure duration, delay, and easing curve.
The form step type provides native input fields for collecting structured user data. Each form can contain multiple fields with validation, conditional visibility, and custom configuration.
| Type | Description | Example Use Case |
|---|
text | Single-line text input | Name, username |
textarea | Multi-line text input | Bio, notes |
number | Numeric input with stepper | Age, quantity |
email | Email input with validation | Email address |
phone | Phone number input | Contact number |
date | Date picker | Birthday, start date |
time | Time picker | Preferred time |
datetime | Combined date and time picker | Appointment scheduling |
select | Dropdown or scrollable picker | Country, category |
slider | Numeric slider with min/max | Budget, intensity level |
toggle | On/off switch | Opt-in preferences |
stepper | Increment/decrement counter | Number of items |
segmented | Segmented control for few options | Gender, frequency |
password | Secure text input with visibility toggle | Password, PIN |
rating | Star rating input | Satisfaction, preference |
range_slider | Dual-handle range slider | Price range, age range |
image_picker | Photo picker from gallery or camera | Profile photo, document |
color | Color picker or preset swatches | Theme preference, branding |
url | URL input with validation | Website, portfolio link |
multiline_chips | Tag-style multi-select chips with optional free-text entry | Interests, skills |
signature | Freehand signature drawing pad | Agreement, consent |
location | Autocomplete location search | City, address, country |
Field Validation
Form fields support built-in and custom validation:
- Required fields — marked in the Console, the SDK prevents advancing until filled
- Regex patterns — custom validation (e.g.,
^[A-Z]{2}\d{4}$ for a code format)
- Min/max values — for
number, slider, and stepper fields
- Max length — for
text and textarea fields
Conditional Fields
Fields can depend on other fields using depends_on rules. For example, a “Company name” field can appear only when the user selects “Employed” in a previous field. Supported operators: equals, not_equals, contains, not_empty, empty, gt, lt, is_set.
Form field values are included in the responses map passed to onOnboardingCompleted, keyed by step ID. Each step’s value is a map of field ID to field value.
override fun onOnboardingCompleted(flowId: String, responses: Map<String, Any>) {
val formData = responses["profile_step"] as? Map<String, Any>
val name = formData?.get("full_name") as? String
val age = formData?.get("age") as? Int
val email = formData?.get("email") as? String
// Use collected data to personalize the experience
}
Location Fields
The location field type provides an autocomplete search input that returns structured location data including city, state, country, coordinates, and timezone.
When the user starts typing (e.g., “New York”), the SDK debounces the input (300ms), calls the AppDNA geocoding proxy, and displays a dropdown of suggestions. The user selects a result and the SDK stores the complete structured data.
Location Data Structure
Each location selection contains:
| Field | Type | Example |
|---|
formatted_address | String | "New York, New York, United States" |
city | String | "New York" |
state | String | "New York" |
state_code | String | "NY" |
country | String | "United States" |
country_code | String | "US" |
latitude | Double | 40.7128 |
longitude | Double | -74.0060 |
timezone | String | "America/New_York" |
timezone_offset | Int | -300 (minutes from UTC) |
postal_code | String? | null |
Accessing Location Data
Use AppDNA.getLocationData(fieldId) to access the selected location from anywhere in your app:
val location = AppDNA.getLocationData(fieldId = "user_location")
if (location != null) {
Log.d("Loc", "City: ${location.city}") // "New York"
Log.d("Loc", "Country: ${location.country_code}") // "US"
Log.d("Loc", "Timezone: ${location.timezone}") // "America/New_York"
Log.d("Loc", "Coords: ${location.latitude}, ${location.longitude}")
}
Template Engine
Location data is accessible in dynamic content templates:
Welcome from {{onboarding.location_step.user_location.city}}!
Your timezone: {{onboarding.location_step.user_location.timezone}}
Configuration in Console
In the onboarding flow editor, add a location field to a form step and configure:
| Option | Description | Default |
|---|
| Location Type | Filter results: city, address, region, country | city |
| Bias Country | ISO country code to prioritize results (e.g., US) | None |
| Language | Language for results (e.g., en, fr) | en |
| Min Characters | Characters required before search triggers | 2 |
Location autocomplete uses a server-side proxy — no third-party SDK is added to your app binary. The geocoding provider (Mapbox by default) can be configured in Settings > Geocoding.
Interactive Chat Steps
The interactive_chat step type renders a conversational UI that forwards each user message to your webhook and renders the reply. This is how you integrate your own LLM, agent, or rule-based backend into an onboarding flow.
How it works
- User types a message in the chat step.
- SDK
POSTs the conversation payload to the webhook_url configured in the Console (with any custom headers you set).
- Your server responds with JSON in the schema below.
- SDK renders the AI reply, quick-reply buttons, media, etc.
The SDK handles turn limits, typing indicators, ratings, and quick-reply routing — your webhook only needs to return the next reply.
Request payload (SDK → your webhook)
{
"event": "chat_message",
"flow_id": "onboarding_v1",
"step_id": "chat_intro",
"app_id": "app_abc123",
"user_id": "user_xyz",
"conversation": {
"turn": 2,
"user_message": "I've been having vivid dreams lately",
"max_turns": 5,
"remaining_turns": 3,
"messages": [
{ "role": "ai", "content": "Hi, what brings you here?", "id": "msg_a0", "timestamp": "2026-04-15T10:00:00Z" },
{ "role": "user", "content": "I've been having vivid dreams...", "id": "msg_u1", "timestamp": "2026-04-15T10:00:12Z" }
]
},
"context": { "threadId": "thread_UXJWTKpSvBRWpGEhHLXqnA6O" },
"rating": null
}
Your headers are forwarded verbatim. For bearer-token APIs, set the header value to Bearer YOUR_TOKEN (the SDK does not add the Bearer prefix automatically).
The context object is opaque session state you control — whatever you returned in data on a previous turn gets echoed back here on every subsequent call. This is how you integrate threaded AI backends (OpenAI Assistants, hosted LLMs with threadId, etc.) without replaying the full history on every request. context is absent on turn 1 (your server hasn’t written anything yet) and accumulates across turns; later values overwrite earlier ones per-key (last-write-wins). See Threaded backends below for the full pattern.
Response schema (your webhook → SDK)
{
"action": "reply",
"messages": [
{ "content": "Tell me more about the most recent one." }
]
}
Only messages[].content is required — every other field is optional. The SDK reads messages[] to render chat bubbles, so if you omit it or return a different shape, the reply won’t render.
Returning a response like {"reply": "..."} without wrapping it in the messages array will silently fail — the SDK decodes unknown fields as null and shows nothing. Always wrap your reply in messages[{ "content": "..." }].
Full response schema
| Field | Type | Description |
|---|
action | "reply" | "reply_and_complete" | "error" | Default "reply". "reply_and_complete" renders the reply then ends the chat. |
messages | Array<{ content, media?, delay_ms? }> | Messages to render. Empty array = silent turn. |
messages[].content | string | The bubble text. |
messages[].media | { type, url, alt_text? } | Optional media — type is "image", "lottie", or "link". |
messages[].delay_ms | number | Delay before the bubble appears (typing simulation). |
quick_replies | Array<{ id, text }> | Buttons rendered under the latest AI reply. Tapping one sends it as the user’s next message. |
force_complete | boolean | End the conversation after this reply. |
completion_message | string | Final message shown when the chat completes. |
data | object | Free-form JSON. Two uses: (1) stored in the onboarding response bundle as webhook_data for analytics/later steps; (2) echoed back to your webhook in context on every subsequent turn — use this for threadId, session_id, or any opaque server state you need to round-trip. |
Unknown fields in your response are ignored, so you can also include your own server-side state at the top level (it just won’t round-trip — use data for anything you want back).
Minimal working example
Node.js (Express):
app.post('/chat', async (req, res) => {
const { conversation } = req.body;
const userMessage = conversation.user_message;
const replyText = await myLLM.complete(userMessage);
res.json({
action: "reply",
messages: [{ content: replyText }],
});
});
Threaded backends: round-tripping session state
If your AI service uses a thread/session handle (OpenAI Assistants API, a hosted LLM with conversation memory, or any third-party AI proxy that mints a session id), do not replay the full conversation history on every turn — latency and cost grow linearly with conversation length. Instead, return your session handle in data once; the SDK accumulates it and echoes it back in context on every subsequent turn.
The wire contract in plain English:
- You write to
context by returning fields under data in any response.
- You read from
context off req.body.context on every request after the first.
- Keys are merged across turns; later values overwrite earlier ones per-key.
- The SDK never inspects or mutates
context — it’s opaque to us. You choose the keys and the shape.
Example: integrating a threaded AI service keyed by threadId. If your backend rotates the thread (expiry, failure, migration), just return the new id in data — the SDK overwrites the cached value and uses it on the next turn automatically.
app.post('/chat', async (req, res) => {
const { conversation, context } = req.body;
// Resume if we have a threadId; create a fresh one otherwise
let threadId = context?.threadId;
if (!threadId) {
threadId = await aiService.createThread();
}
let replyText;
try {
replyText = await aiService.sendMessage(threadId, conversation.user_message);
} catch (err) {
// Thread expired or invalid — start fresh and retry once
if (err.code === 'thread_expired') {
threadId = await aiService.createThread();
replyText = await aiService.sendMessage(threadId, conversation.user_message);
} else {
throw err;
}
}
res.json({
action: "reply",
messages: [{ content: replyText }],
data: { threadId }, // round-tripped to every subsequent call
});
});
No Redis, no per-user cache, no history replay. The context round-trip IS your session storage.
The same pattern works for anything opaque you need per-conversation: API rate-limit bucket ids, A/B cohort labels, tool-call state, reasoning scratchpads — whatever your AI backend needs to resume, stash it in data and read it from context on the next turn.
Console configuration
In the onboarding flow editor, add a step of type Interactive Chat, then set:
| Field | Purpose |
|---|
| Webhook URL | Where the SDK POSTs each turn |
| Headers | Custom headers — for bearer auth, the full value must be Bearer <token> |
| Timeout (ms) | How long to wait before showing error_text |
| Retry count | Number of retries on transient network errors |
| Error text | Shown in-chat when the webhook fails or times out |
| Persona | Name, role, avatar — rendered in the header |
| Max turns | Caps the conversation length |
| Auto-messages | Pre-scripted AI messages keyed by turn number |
| Quick replies | Static buttons always shown |
| Turn actions | Trigger rating prompts / inject messages at specific turns |
| Style | Colors, fonts, bubble styling |
Auto-tracked events
| Event | When |
|---|
chat_message_sent | User sends a message |
chat_message_received | Webhook reply is received (even if empty) |
chat_webhook_error | Webhook throws, times out, returns a non-2xx status, or returns invalid JSON. Includes http_status and a truncated response_body when the error is a non-2xx response, so integration bugs surface immediately instead of silently showing an empty reply. |
chat_rating_submitted | User submits a rating in a turn action |
The standard onboarding step events (onboarding_step_viewed, onboarding_step_completed) also fire for chat steps — there are no separate chat_step_viewed / chat_step_completed events.
Async Step Hooks
The onboarding delegate supports two suspend functions that let you intercept step transitions for server-side validation, dynamic content loading, or custom routing logic.
onBeforeStepAdvance
Called before the SDK advances to the next step. Return a StepAdvanceResult to control what happens next:
override suspend fun onBeforeStepAdvance(
flowId: String,
fromStepId: String,
stepIndex: Int,
stepType: String,
responses: Map<String, Any>,
stepData: Map<String, Any>?,
): StepAdvanceResult {
// Example: validate a referral code with your backend
if (stepType == "form") {
val formData = responses[fromStepId] as? Map<String, Any>
val code = formData?.get("referral_code") as? String
if (code != null) {
val isValid = validateReferralCode(code) // your suspend function
if (!isValid) {
return StepAdvanceResult.Block("Invalid referral code. Please try again.")
}
return StepAdvanceResult.ProceedWithData(mapOf("referral_validated" to true))
}
}
return StepAdvanceResult.Proceed
}
StepAdvanceResult
| Case | Description |
|---|
Proceed | Continue to the next step normally |
ProceedWithData(data) | Continue and merge additional data into the session |
Block(message) | Block advancement and show an error message (red banner) to the user |
Stay(message?) | Stay on the current step without advancing and without showing an error. Pass a non-null message to render a green success banner; pass null (or omit) to stay silent and let your code handle UI |
SkipTo(stepId, data?) | Skip to a specific step by ID, optionally with data merged into the session |
Use Stay for “I handled it; keep the user here”
Use Stay when your hook handled the user’s action — for example, sent a password-reset email — and you want the user to remain on the same step. This is distinct from Block (error styling) and from Proceed (advances).
"reset_password" -> {
val email = (responses[fromStepId] as? Map<String, Any>)?.get("email") as? String ?: ""
try {
myAuthBackend.sendPasswordReset(email)
StepAdvanceResult.Stay("Reset email sent to $email")
} catch (e: Exception) {
StepAdvanceResult.Block("Couldn't send reset email. Please try again.")
}
}
onBeforeStepRender
Called before a step is rendered. Return a StepConfigOverride to dynamically modify the step’s content:
override suspend fun onBeforeStepRender(
flowId: String,
stepId: String,
stepIndex: Int,
stepType: String,
responses: Map<String, Any>,
): StepConfigOverride? {
// Pre-fill form fields based on user data
if (stepId == "profile_step") {
return StepConfigOverride(
fieldDefaults = mapOf(
"email" to currentUser.email,
"name" to currentUser.displayName,
),
title = "Welcome back, ${currentUser.firstName}!"
)
}
return null
}
Both hooks are suspend functions — the SDK shows a loading indicator while waiting for your response. If either hook throws an exception or times out, the SDK proceeds normally.
Row Direction and Distribution
The row content block supports configurable direction and distribution, set in the Console:
| Property | Options | Description |
|---|
direction | horizontal, vertical | Axis along which child blocks are arranged |
distribution | equal, fill, start, center, end, space_between, space_around | How child blocks are distributed within the row |
// Example: Two buttons side by side, equally spaced
Row (direction: horizontal, distribution: equal)
├── Button "Skip"
└── Button "Continue"
Buttons support gradient backgrounds configured in the Console:
| Property | Type | Description |
|---|
gradient_colors | List<String> | Array of hex color stops (e.g., ["#FF6B6B", "#4ECDC4"]) |
gradient_direction | String | horizontal, vertical, diagonal_tl_br, diagonal_tr_bl |
Gradients override the solid background_color when set.
Select Display Styles
input_select form-input blocks (and select form fields) support three display styles:
| Style | Description |
|---|
dropdown | Native dropdown picker. Best for long option lists (5+ items). |
stacked | Vertically stacked option buttons. Default style. |
grid | Grid layout with 2 or 3 columns. Good for visual options with icons. |
The display style is configured per select field in the Console under field_config.display_style. Question-step option lists always render in a fixed grid layout; the styles above apply specifically to input_select / select.
Progress Bar Custom Colors
The progress_bar content block supports custom color configuration:
| Property | Type | Description |
|---|
fill_color | String | Hex color for the filled portion |
track_color | String | Hex color for the unfilled track |
fill_gradient | List<String> | Gradient color stops for the fill (overrides fill_color) |
corner_radius | Number | Corner radius of the progress bar |
height | Number | Height of the progress bar in dp |
Colors are configured per block in the Console. When not set, the SDK uses the app’s primary theme color.
Per-Step Progress Visibility
The progress indicator can be hidden on specific steps while still counting them in the total progress. This is useful for splash screens, permission prompts, or transition steps where the progress bar would be distracting.
Configure hide_progress per step in the Console under Step Design > Logic. When enabled, the progress bar is hidden on that step but the step still contributes to the overall progress calculation (e.g., step 3 of 5 still advances progress to 60%).
| Property | Type | Default | Description |
|---|
hide_progress | Boolean | false | Hides the progress indicator on this step only |
This is a per-step override of the flow-level show_progress setting. If show_progress is false at the flow level, the progress bar is hidden on all steps regardless of hide_progress.
Conditional Branching
Onboarding flows support conditional branching — the next step changes based on the user’s answer. Branching is configured entirely in the Console:
Example:
Step 1: "What's your fitness goal?"
→ "Lose weight" → Step 2a: Weight loss program
→ "Build muscle" → Step 2b: Muscle building program
→ "Stay active" → Step 2c: General fitness
To configure branching:
- Open your flow in the Console (Onboarding > Flows)
- On a question step, click Add branching rule
- Map each answer option to a target step
- The SDK handles routing automatically — no code needed
All answers (including branched paths) are returned in the responses map in onOnboardingCompleted.
Auto-Tracked Events
The onboarding module automatically tracks the following events:
| Event | Triggered When |
|---|
onboarding_flow_started | User begins an onboarding flow |
onboarding_step_viewed | A step is displayed to the user |
onboarding_step_completed | User completes a step |
onboarding_step_skipped | User skips a step |
onboarding_flow_completed | User reaches the end of the flow |
onboarding_flow_dismissed | User dismisses the flow before completing |
Each event includes the flowId, stepId, and stepIndex where applicable.
The responses map passed to onOnboardingCompleted contains all user answers keyed by step ID. Use these responses to personalize the user experience or send them to your backend for analysis.
Configuration in Console
Onboarding flows are managed in the AppDNA Console:
- Navigate to Onboarding > Flows.
- Create a new flow or edit an existing one.
- Add steps (welcome, question, value_prop, form, interactive_chat, custom) and configure their content.
- Set targeting rules to control which users see the flow.
- Publish the flow to make it available to the SDK via the config bundle.
Flows must be published in the Console before they appear in the SDK. Draft flows are not delivered to client devices.
Full Example
import ai.appdna.sdk.AppDNA
import ai.appdna.sdk.onboarding.AppDNAOnboardingDelegate
import ai.appdna.sdk.onboarding.StepAdvanceResult
import ai.appdna.sdk.onboarding.StepConfigOverride
import androidx.fragment.app.FragmentActivity
class OnboardingCoordinator(
private val activity: FragmentActivity,
) : AppDNAOnboardingDelegate {
init {
AppDNA.onboarding.setDelegate(this)
}
fun showOnboardingIfNeeded() {
// Pass null to show the active flow from remote config
val presented = AppDNA.presentOnboarding(
activity = activity,
flowId = null,
listener = this,
)
if (!presented) {
// No active flow or config not loaded yet
navigateToMainApp()
}
}
// MARK: AppDNAOnboardingDelegate
override fun onOnboardingStarted(flowId: String) {
Log.d("Onboarding", "Starting flow: $flowId")
}
override fun onOnboardingStepChanged(
flowId: String,
stepId: String,
stepIndex: Int,
totalSteps: Int,
) {
// Track progress
}
override fun onOnboardingCompleted(flowId: String, responses: Map<String, Any>) {
// Personalize based on responses
val goal = responses["fitness_goal"] as? String
if (goal != null) {
AppDNA.identify(userId = currentUserId, traits = mapOf("fitness_goal" to goal))
}
navigateToMainApp()
}
override fun onOnboardingDismissed(flowId: String, atStep: Int) {
navigateToMainApp()
}
private fun navigateToMainApp() { /* ... */ }
}
Paywall trigger nodes — entitlement-aware skip + restore behavior (1.0.32+)
Onboarding flows can include paywall_trigger graph nodes that present a paywall in the middle of a flow and route the user via per-outcome targets (on_success_target, on_fail_target, on_dismiss_target).
Starting in 1.0.32 the SDK ships two behavioral changes around these triggers — both controllable from the console editor.
Entitlement-aware skip (default ON)
Each paywall_trigger node carries a skip_if_subscribed flag (default true). When the SDK reaches the trigger, it checks AppDNA.billing.hasActiveSubscription() (synchronous; reads the in-memory EntitlementCache) first:
- User has an active subscription → SDK skips presentation, emits an
onboarding_paywall_skip event with reason=user_already_subscribed, and routes the flow via the configured skip target (see below).
- User has no active subscription → SDK presents the paywall as before; outcome routing is unchanged.
This stops returning users from seeing a paywall they already paid for. Already-subscribed users move past the trigger silently.
If you sell upsell paywalls (a higher tier to a user who already owns the base plan), open the trigger in the onboarding flow editor and uncheck Skip if user already has an active subscription. The trigger then always presents.
☑ Skip if user already has an active subscription ← default for new + legacy nodes
Existing flows authored before 1.0.32 require no migration: nodes without the field default to true at read time.
Subscribed-user skip routing — picking the target
The console editor exposes a dedicated dropdown directly under the Skip if user already has an active subscription toggle: When skipped (user is already subscribed), go to. Four options:
| Option | Stored value | When to pick |
|---|
| Complete flow (recommended) | complete_flow | Subscribed user bypasses the paywall and everything downstream — reaches your home screen immediately. Default for new paywall_trigger nodes. |
| Use post-purchase target | '' (empty) | Falls back to whatever On purchase success is set to — useful when the post-purchase target and the skip target should be identical. |
| Follow downstream edge | continue | Subscribed user continues through the rest of the graph (rare — used for chain-of-paywalls flows that intentionally show more upsells after the gate). |
Go to: <node> | the node id | Route directly to a specific step or end node. |
The SDK resolves the target with this chain at runtime: on_subscribed_skip_target → falls back to on_success_target → falls back to “follow downstream edge”. The fallback means flows authored before 1.0.32 that worked around this by setting on_success_target = complete_flow continue to behave identically.
Warning callout in the editor
If you open a paywall_trigger that has Skip if user already has an active subscription on but the resolved skip target is “Follow downstream edge” and the next node in the graph is another paywall_trigger, the editor displays an inline warning:
Heads up: this paywall is set to skip for subscribed users, but the next step is another paywall trigger — subscribed users will still see it. Set “When skipped” to “Complete flow” to bypass the rest of the paywall chain.
One click on Complete flow in the dropdown clears the warning and fixes the chain. This is the most common authoring trap with multi-paywall flows (main paywall → winback paywall → end); the warning surfaces it before publish.
Restore now auto-dismisses + routes to success
Tapping Restore Subscription on a paywall now:
- Fires
onPaywallRestoreCompleted(paywallId, productIds) on your AppDNAPaywallDelegate.
- Auto-finishes the
PaywallActivity when productIds is non-empty (i.e., the restore actually found entitlements).
- Routes the onboarding flow via the trigger’s
on_success_target — same path as a real purchase.
When the restore call succeeds but finds NO entitlements (productIds: emptyList()) the paywall stays up so the user can attempt a fresh purchase or close manually.
Hosts that handle their own dismiss
If you already finish the PaywallActivity inside your own onPaywallRestoreCompleted delegate body (e.g., to show a “Restored — tap continue when ready” overlay), the existing dispatchedDismiss flag inside PaywallActivity ensures the SDK auto-finish becomes a no-op once your finish() runs. First caller wins.
AppDNA.identify(userId) refreshes the entitlement cache
When you call AppDNA.identify(...) after the user signs in, the SDK now silently calls AppDNA.billing.refreshEntitlementCache() in the background (via the SDK’s lifecycle-bound coroutine scope). The next paywall_trigger gate then reflects the identified user’s Play Billing subscriptions instead of the prior anonymous user’s empty entitlement state.
If your app completes auth out-of-band (SSO callback, OAuth web flow), you can call AppDNA.billing.refreshEntitlementCache() (suspend) or AppDNA.billing.refreshEntitlementCacheFuture() (Java-friendly) manually after auth completes — same effect, no events fired.
Migration risk callout
⚠️ Behavioral change: paywall_trigger nodes now skip presentation for subscribed users by default. If your app intentionally relies on the legacy “always present” behavior (e.g., a confirmation/legal-acceptance screen rendered as a paywall), open the trigger node in the console editor and uncheck Skip if user already has an active subscription.
⚠️ Web-side entitlements (Stripe subscriptions, manually-granted entitlements via web tooling) are NOT consulted by this gate today — only Play Billing entitlements are. If your premium users have only web-side subscriptions, leave Skip if user already has an active subscription unchecked, or implement a host-side gate via AppDNAOnboardingDelegate.onBeforeStepRender (see the async hooks doc) that consults your own backend.
Next Steps
- Present Paywalls at the end of onboarding flows
- Set up Billing to handle purchases triggered from onboarding
- Learn about Offline Support for onboarding config caching