Skip to main content
Run multiple independent Userpilot instances in the same process — for example when an embedded vendor SDK and its host app both initialize Userpilot, each with their own account token. Each instance keeps its own analytics, autocapture routing, storage, and experience UI without interfering with the others.
NoteIf you only have one Userpilot instance in your app, nothing changes. Userpilot(context, token) and all existing autocapture APIs work as before. The rest of this guide is for SDK authors and host apps that need two or more Userpilot instances concurrently.

Quick Reference

ScenarioUse
Single instance (host app only)Userpilot(context, token)isDefault defaults to true, so the host claims default automatically
Vendor SDK that embeds UserpilotUserpilot(context, token) { isDefault = false … } inside the vendor facade
Re-initialize idempotentlyUserpilot(context, token) again — returns the existing instance for the same token
Manual API calls (identify, track, screen, triggerExperience) route to whichever instance you call them on — no extra wiring is needed. UserpilotConfig.isDefault defaults to true. The host application does not need to set isDefault = true explicitly. Embedded vendor SDKs must set isDefault = false.

Event Routing

Every event — autocapture interaction, autocapture screen, or a manual screen(_:) / track(_:) — routes to one Userpilot instance using a single three-tier rule:
  1. Explicit userpilot: argument, when the API exposes one (for example UserpilotComposeNavigationTracker(..., userpilot = ...)).
  2. Anchored owner, when something in the UI hierarchy claims the originating UI (attachToActivity(...), or UserpilotOwner { … } / LocalUserpilotOwner in Compose).
  3. Default fallback — the instance that claimed isDefault (defaults to true).
If none of those resolve to a live instance, the event is a silent no-op. There is no fan-out. In the single-instance case the only registered instance claims the default automatically (isDefault defaults to true), so nothing in your integration changes.

Idempotent Factory

Userpilot(context, token) is a get-or-create factory. UserpilotConfig.isDefault defaults to true, so the first live instance that does not opt out claims the default role automatically. Subsequent calls with the same token return the existing instance:
val a = Userpilot(context, "HOST")   // claims default (isDefault defaults true)
val b = Userpilot(context, "HOST")
// a === b — same storage, socket, and autocapture coordinator. The DSL block on the second call is ignored.
A second instance with a different token that also leaves isDefault at its default and tries to claim the role while another instance already holds it will not displace the existing claimant — the SDK logs a warning and un-anchored events keep routing to whoever already claimed the role. Different tokens coexist as independent instances — each with its own socket, autocapture coordinator, and lifecycle callbacks.

The Default Instance

The SDK tracks the live instance that claimed the default role via isDefault = true. This fallback is used for un-anchored autocapture and screen-tracker call sites.
val hostUserpilot = Userpilot(context, "HOST")
hostUserpilot.track("Added to Cart")
There is intentionally no public default-instance getter. For manual calls, keep and pass the Userpilot instance returned by the factory. In the single-instance case this is your only instance (isDefault defaults to true). In the multi-instance case the default role is typically held by the host app, which does not need to set isDefault = true explicitly. Resolution is claim-based, not order-based, so init order between correctly configured tenants does not matter. Embedded vendor SDKs must opt out:
val acmeUserpilot = Userpilot(context, "ACME") {
    isDefault = false
    enableScreenAutoCapture = true
    enableInteractionAutoCapture = true
    // anchor vendor UI — e.g. attachToActivity in each vendor Activity
}
Only one instance can hold the default role at a time. An instance with isDefault = true (the default) claims that role on registration when the role is unclaimed. If the role is already held, the new claim is rejected (a warning is logged) and the existing claimant keeps the role. This is claim-based, not registration-order-based: registering first does not make an instance the default unless it also opts in with isDefault = true, and there is no first-registered fallback when every instance opts out. Correct setup: the host leaves isDefault at its default (true); every embedded vendor sets isDefault = false. The host then holds the default role regardless of init order — a vendor that opts out never competes for the slot even if it initializes first. Misconfiguration: if two instances both leave isDefault = true, whichever registers while the role is still unclaimed becomes the default; any later conflicting claim is rejected. Do not rely on init order — always set isDefault = false on embedded instances. If no instance claims the default role (every instance set isDefault = false), there is no SDK default fallback and un-anchored events are dropped. Keep isDefault at its default (true) on the host app.

Host app + embedded vendor

Once a vendor SDK opts out and anchors its own UI, the host app does nothing special for default resolution:
// Application.onCreate (host app)
val hostUserpilot = Userpilot(context, "HOST") {
    enableScreenAutoCapture = true
    enableInteractionAutoCapture = true
    // no isDefault = true needed — defaults to true
}

// AcmeSDK (vendor SDK facade)
val acmeUserpilot = Userpilot(context, "ACME") {
    isDefault = false
    enableScreenAutoCapture = true
    enableInteractionAutoCapture = true
}
Both instances are now live and independent.

Compose Routing

Wrap each Compose subtree in UserpilotOwner(yourUserpilot) { … }. Nested wrappers shadow outer ones, so an embedded SDK can take ownership of its own UI even when the host declares a different owner at the root.
@Composable
fun AcmeSdkUi(content: @Composable () -> Unit) {
    UserpilotOwner(userpilot = acmeUserpilot) {
        content()
    }
}

setContent {
    UserpilotOwner(userpilot = hostUserpilot) {
        NavHost(navController = navController, startDestination = "home") {
            composable("home") { HomeScreen() }
            composable("acmeSdkSurface") {
                AcmeSdkUi { AcmeScreens() }
            }
        }
    }
}
UserpilotComposeNavigationTracker also accepts an explicit userpilot: argument:
UserpilotComposeNavigationTracker(
    navController = navController,
    userpilot = acmeUserpilot,
)
Resolution priority:
  1. The userpilot: argument if non-null.
  2. LocalUserpilotOwner.current if a UserpilotOwner is in scope.
  3. The SDK default fallback.
Modifier.userpilotScreen(...) resolves via LocalUserpilotOwner → SDK default fallback.

View Routing

For Android Views, ownership is anchored on the hosting Activity’s decor view. Call attachToActivity from each owning Activity’s onCreate:
class AcmeLoginActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        acmeUserpilot.attachToActivity(this)
        setContentView(R.layout.activity_login)
    }
}

Interaction events

Click, value-change, and text-input handlers resolve the owner via:
  1. Parent-chain walk looking for the userpilot_owner_tag set by attachToActivity.
  2. Context fallback — unwrap View.context to the hosting Activity and read the tag from its decor view (for example Dialog clicks).
  3. Default fallback — the SDK default fallback handles the event.
Dialogs and bottom-sheet dialogs are auto-tagged on first Dialog.show(), so subsequent inner clicks resolve cheaply via the parent chain.

Screen events

Each instance installs its own lifecycle callbacks, but a per-tracker gate ensures only one tenant publishes a screen event per Activity or Fragment:
Anchored?Behaviour
Decor view tagged by attachToActivityOnly the anchored tenant fires.
Not anchoredOnly the SDK default fallback fires.
With multiple tenants registered, an un-anchored Activity emits exactly one screen event on the default — not one copy per tenant.

Forwarding Events to the Host

By default, autocapture events publish only on the instance that owns the originating UI. A vendor-owned screen or click is reported to the vendor tenant only. If the host app wants autocapture events that originate inside embedded vendor SDKs, opt in on the host (default) instance:
val hostUserpilot = Userpilot(context, "HOST") {
    enableScreenAutoCapture = true
    enableInteractionAutoCapture = true
    allowReceiveEventsFromExternalSource = true
}
When enabled:
  • Autocapture events that resolve to a non-default instance are published to that instance and forwarded to the default instance.
  • The flag is read only on the resolved default instance. Setting it on a vendor instance has no effect.
  • Forwarded events are delivered unchanged through the host’s publisher and UserpilotAnalyticsListener. There is no extra tagging.
  • The host’s own events are never duplicated to itself, and forwarding never re-routes, so there is no fan-out loop.
Routing still decides the owning tenant; forwarding only adds a copy to the default when the default opted in.

Gradle Plugin Scope

The Userpilot Gradle plugin instruments bytecode at build time. By default it uses InstrumentationScope.ALL, which transforms your module plus every dependency JAR/AAR — correct for end-user apps. If you ship an SDK embedded in someone else’s app, restrict instrumentation to your module:
plugins {
    id("com.userpilot.plugin") version "<latest_version>"
}

userpilot {
    enabled.set(true)
    composeEnabled.set(true)
    viewEnabled.set(true)
    instrumentationScope.set("PROJECT")
}
PROJECT (case-insensitive) restricts class-visitor transformation to the current module’s compiled classes. The embedding app can apply its own plugin configuration independently. Any other value keeps the default ALL behavior.

Routing Matrix

SurfaceRouting
Manual API (identify, track, screen, triggerExperience, endExperience)Routes to whichever instance you called the method on.
Activity / Fragment screen autocaptureAnchored tenant if decor view is tagged, otherwise the SDK default fallback. One tenant per screen.
Compose interaction autocaptureUserpilotOwner (LocalUserpilotOwner) → SDK default fallback.
Compose navigation trackeruserpilot: arg → LocalUserpilotOwner → SDK default fallback.
Compose Modifier.userpilotScreen(...)LocalUserpilotOwner → SDK default fallback.
View interaction autocaptureParent-chain walk → context walk → SDK default fallback.
View-less callbacks (TabHost, Material date/time pickers, popup menus)SDK default fallback.
Each instance owns its own capture config (enableInteractionAutoCapture, enableInteractionTextCapture, enableInteractionAccessibilityLabelCapture, enableInteractionValueCapture), so two tenants can hold different autocapture preferences in the same process.

Limitations

  • View-less callbacks cannot resolve an anchored owner. They always route to the SDK default fallback (the isDefault claimant), or are dropped when no instance holds that role.
  • Same-process re-init with the same token returns the existing instance and discards the DSL block on the second call.
  • Activity screen events publish once per Activity or Fragment — to whichever tenant owns the anchor, or to the default when un-anchored. If both tenants need the full session, re-emit the screen manually with instance.screen("…").

Complete Example

// Host Application.onCreate
val hostUserpilot = Userpilot(this, "HOST") {
    enableScreenAutoCapture = true
    enableInteractionAutoCapture = true
    // isDefault defaults to true — claims default automatically
}

// AcmeSDK (vendor SDK facade)
class AcmeSDK(context: Context) {
    val acmeUserpilot = Userpilot(context, "ACME") {
        isDefault = false
        enableScreenAutoCapture = true
        enableInteractionAutoCapture = true
    }

    fun identifyAcmeUser(id: String) = acmeUserpilot.identify(id)
}

hostUserpilot.identify("host-42")
AcmeSDK(this).identifyAcmeUser("acme-99")
hostUserpilot.track("Added to Cart")