Weir docsv4
View as Markdown

Observe — a funnel for the onboarding you already shipped

Permalink to Observe — a funnel for the onboarding you already shipped

Observe 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 iOS

WeirObserve 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 Android

The 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 Native

Observe 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-native
import { Weir } from "weir-react-native/observe";
Availability: npm install weir-react-native works from 1.1.0; until that version is published, Weir vendors the built package into partner apps during onboarding (the recipe is scripts/vendor-weir-sdk.sh in a consuming repo, and PACKAGING.md in 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 truth

The 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 events

Every 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.typeEmitted onExact payload
flow_startedfirst screen() of a session{type:"flow_started", flow, screenCount, declaredScreens:[…]}
screen_impressionevery screen(){type:"screen_impression", screen, index, declared, props}
screen_exitleaving a screen{type:"screen_exit", screen, index, reason, props}
paywall_shownpaywallShown(){type:"paywall_shown", paywallId?, screen, index, props}
purchase_intentpurchaseIntent(){type:"purchase_intent", productId, screen, props}
purchase_resultpurchaseResult(){type:"purchase_result", productId, outcome, error?}
flow_completedcompleted(){type:"flow_completed", flow, screensSeen, durationMs, props}
customtrack(name, …){type:"custom", name, props}
health_sdk_heartbeatforeground and hourlyRFC-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 Trust

Observe 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" — and no_data is 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% of screen_impression rows in 24 hours carry declared: false, red above 10%. Its finding lists the undeclared names; its action is to add them to the screens: array in Weir.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_result is a funnel marker, never a financial source of truth.
  • No PII: userId is 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.
Generated from this repo's source at build time — packages/mcp/src/tools/index.ts and packages/spec/src/events.ts are the ground truth for the tool and event tables above. llms-full.txt