Observe — a funnel for the onboarding you already shipped
Permalink to Observe — a funnel for the onboarding you already shippedObserve is the smaller ask: your app keeps its onboarding UI exactly as built — no flow spec, no served config, no rewrite. You add one dependency, name your screens once in order, and get the same funnel, dwell, and completion reads a served Weir flow produces — no config fetch, no signature verification, no OTA. The whole integration is three calls: Weir.observe(...) once at launch with a URL, a write token, and the ordered screen names; Weir.screen("name") once per screen; and Weir.completed() where onboarding ends.
For why this is the right first integration, and the retained evidence behind the fifteen-minute and funnel-correctness claims, see Why Observe.
iOS
Permalink to iOSWeirObserve is a separate SPM product in the public iOS SDK. It depends on WeirCore only — it contains no SwiftUI and never links WeirUI. In Xcode, add this exact package dependency and select the WeirObserve product for the app target:
.package(
url: "https://github.com/cynisca/weir-sdk-ios.git",
exact: "1.1.0"
)
// product: "WeirObserve"Then the three calls:
import WeirObserve
// Once, at launch. The screen list is the flow's spine — see "The screens array" below.
Weir.observe(
appId: "cutorbulk",
writeToken: "wko_live_…",
endpoint: URL(string: "https://api.agentwallie.com/events")!,
flow: "onboarding",
screens: ["welcome", "goals", "experience", "plan", "paywall"]
)
// Once per screen.
.onAppear { Weir.screen("goals") }
// Where onboarding ends.
Weir.completed()Weir.observe is idempotent and safe to call before any UI exists. Without a token or endpoint it degrades to a durable local queue that never uploads. Full API surface:
public enum Weir {
public static func observe(
appId: String, writeToken: String, endpoint: URL,
flow: String = "onboarding",
screens: [String],
userId: String? = nil,
flushInterval: TimeInterval = 30,
sessionTimeout: TimeInterval = 1800 // 30 min backgrounded → abandon
)
public static func screen(_ name: String, index: Int? = nil,
properties: [String: JSONValue] = [:])
public static func completed(properties: [String: JSONValue] = [:])
public static func paywallShown(_ paywallId: String? = nil,
properties: [String: JSONValue] = [:])
public static func purchaseIntent(product: String,
properties: [String: JSONValue] = [:])
public static func purchaseResult(product: String, outcome: PurchaseOutcome,
error: String? = nil)
public static func track(_ name: String, _ properties: [String: JSONValue] = [:])
public static func setUserId(_ id: String?)
public static func flush()
}Dwell timing is owned by the SDK: each Weir.screen(_:) closes the previous screen and opens the new one, so the host never times anything. Host properties land namespaced under payload.props, never at the payload root, so a host key named type or index can never shadow a field the funnel reads.
Android
Permalink to AndroidThe Android SDK is split into three Gradle modules: weir-core (event queue, uploader, envelope types, device context), weir-observe (the facade above), and weir (the existing Compose renderer). An Observe integration pulls only the Compose-free pair — weir-core comes in transitively.
Published via JitPack from <https://github.com/cynisca/weir-sdk-android>. Add the repository in settings.gradle.kts:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}then the dependency in your module's build.gradle.kts:
implementation("com.github.cynisca.weir-sdk-android:weir-observe:1.1.0")Requires JDK 17 and minSdk 26. weir-observe and weir-core do not apply the Compose compiler plugin and declare no androidx.compose dependency, so instrumenting your own screens never links the renderer — verify it yourself with ./gradlew dependencies --configuration releaseRuntimeClasspath.
React Native
Permalink to React NativeObserve ships as a subpath export of the existing RN package — weir-react-native/observe, not a separate npm package. Its JS imports nothing from the renderer. (Accepted v1 limitation: the RN native module links the full native SDK, so an RN Observe app carries the renderer in its binary.)
npm install weir-react-nativeimport { Weir } from "weir-react-native/observe";Availability:npm install weir-react-nativeworks from 1.1.0; until that version is published, Weir vendors the built package into partner apps during onboarding (the recipe isscripts/vendor-weir-sdk.shin a consuming repo, andPACKAGING.mdin the SDK). Vendoring stays supported after 1.1.0 for apps that cannot take a registry dependency.
Call Observe once from app startup, then mark each existing native screen when it appears. Do not mount <WeirFlow>, do not fetch config, and do not publish a flow for Observe — the app keeps rendering its own onboarding.
import { useEffect } from "react";
import { Weir } from "weir-react-native/observe";
const ONBOARDING_SCREENS = ["welcome", "goals", "experience", "plan", "paywall"];
export default function App() {
useEffect(() => {
Weir.observe({
appId: "cutorbulk",
endpoint: "https://api.agentwallie.com/events",
writeToken: process.env.WEIR_WRITE_TOKEN!,
flow: "onboarding",
screens: ONBOARDING_SCREENS,
});
}, []);
// When each existing screen renders:
// Weir.screen("welcome");
// Weir.paywallShown("main_paywall");
// Weir.purchaseIntent("cutorbulk.pro.monthly");
// Weir.completed();
}Call Weir.purchaseIntent(productId) exactly when the user presses the purchase or trial CTA, before starting the store or billing request. Pass the app's real store product identifier. If an existing paywall exposes no product identifier, use the deterministic fallback `${appId}.pro.monthly (for example, cardclub-observe.pro.monthly) until the app supplies its catalog identifier. Do not emit purchaseResult` unless a billing integration returns an outcome.
For production Expo builds, the native ingest token still comes from build metadata, not from JavaScript. Add the weir-react-native Expo config plugin before expo prebuild, provide WEIR_DEMO_INGEST_TOKEN only to that prebuild environment, and set requireIngestToken: true for release builds. The writeToken parameter keeps the cross-platform Observe API shape; on RN, the native module reads the actual upload token from the build-time config.
The endpoint is different: it is a non-secret runtime URL passed to Weir.observe(...). Use a literal release URL as above or your app's established runtime-config mechanism. Do not read it from an unconfigured process.env in a Release Expo bundle: that value is not injected at runtime, so the queue has no endpoint and cannot upload Observe events.
// app.config.js
const app = require("./app.json");
module.exports = {
expo: {
...app.expo,
plugins: [
...(app.expo.plugins ?? []),
["weir-react-native", {
observeOnly: true,
requireIngestToken: true,
}],
],
},
};The screens array is the single source of truth
Permalink to The screens array is the single source of truthThe screens: array declared once in Weir.observe(...) defines step order for the funnel. The API deliberately has no per-call index you must keep in sync with the real order: a hand-typed index is a second source of truth that drifts as the app evolves, and the funnel orders steps by that index — drift corrupts the funnel silently, with no error anywhere in the pipeline. The declared array is authored once, is diffable in review, and becomes the screen id list if the app later upgrades to a served flow — same names, same ids, comparable funnel history across the cutover.
Weir.screen(_:index:) keeps an explicit index: override for genuinely dynamic order. A name that is not in the declared array still emits, with payload.declared = false, and surfaces as a trust finding (below) — it is never silently absorbed.
Wire events
Permalink to Wire eventsEvery event is the standard Weir envelope: flowId, sessionId, ts, seq, eventId, launchId, elapsedMs. The config fields (configId/configVersion) are absent — their absence is exactly what marks a row as hand-instrumented. The event types an Observe app produces:
payload.type | Emitted on | Exact payload |
|---|---|---|
flow_started | first screen() of a session | {type:"flow_started", flow, screenCount, declaredScreens:[…]} |
screen_impression | every screen() | {type:"screen_impression", screen, index, declared, props} |
screen_exit | leaving a screen | {type:"screen_exit", screen, index, reason, props} |
paywall_shown | paywallShown() | {type:"paywall_shown", paywallId?, screen, index, props} |
purchase_intent | purchaseIntent() | {type:"purchase_intent", productId, screen, props} |
purchase_result | purchaseResult() | {type:"purchase_result", productId, outcome, error?} |
flow_completed | completed() | {type:"flow_completed", flow, screensSeen, durationMs, props} |
custom | track(name, …) | {type:"custom", name, props} |
health_sdk_heartbeat | foreground and hourly | RFC-014 §5.2 heartbeat |
screen_exit and paywall_shown (and purchase_intent, purchase_result, custom) also carry screenId and screenDwellMs on the envelope; screen_impression carries screenId. screen_exit fires when a screen closes — reason is one of advance, back, background, flow_end, or abandon. purchase_result's outcome is one of purchased, cancelled, or failed.
There is no flow_abandoned event type. Abandonment is derived: a session with flow_started and no flow_completed past the settle window is what the funnel's completion rate computes, and the SDK emits a screen_exit with reason: "abandon" on the next launch after the session timeout — which turns "they left" into "they left on the plan screen after 41 seconds."
Trust
Permalink to TrustObserve apps get the same RFC-014 telemetry-trust checks as served apps. That inheritance is the point, not a bolt-on:
- An app with zero events reads
health: "no_data"— andno_datais never shown as green. Funnel health alarms when data stops arriving; it does not silently go quiet. - One check is Observe-specific:
observe_screen_undeclared— amber when more than 1% ofscreen_impressionrows in 24 hours carrydeclared: false, red above 10%. Its finding lists the undeclared names; its action is to add them to thescreens:array inWeir.observe, in order — funnel step indexes are unreliable until you do. This is instrumentation drift, the Observe-native form of "the config and the app disagree," and it is invisible without the check.
Non-goals for v1
Permalink to Non-goals for v1- No served UI, OTA config, or signature verification — that is the serve path (Quickstart).
- No experiments or variant assignment on Observe apps.
- No revenue attribution:
purchase_resultis a funnel marker, never a financial source of truth. - No PII:
userIdis an opaque host-supplied string; the SDK collects no identifiers of its own beyond the existing device context.
Next
Permalink to Next- Install the Weir skill / MCP server — the toolkit that authors flows and reads funnels (
weir funnel,weir health). - Quickstart for agents — the served-flow loop, for when you want Weir to render onboarding too.
- Concepts — the instrumentation contract Observe shares, and the delivery model it does not use.
- Monitoring — operating a live flow: the funnel and health reads an Observe integration watches.
- Why Observe — the positioning, the non-goals, and the retained proof.