# 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](/docs/why-observe/).

## 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:

```swift
.package(
    url: "https://github.com/cynisca/weir-sdk-ios.git",
    exact: "1.1.0"
)
// product: "WeirObserve"
```

Then the three calls:

```swift
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:

```swift
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

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`:

```kotlin
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`:

```kotlin
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

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.)

```bash
npm install weir-react-native
```

```tsx
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.

```tsx
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.

```js
// 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

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

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.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

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

- No served UI, OTA config, or signature verification — that is the serve path
  ([Quickstart](/docs/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

- [Install the Weir skill / MCP server](/docs/install/) — the toolkit that authors flows and reads
  funnels (`weir funnel`, `weir health`).
- [Quickstart for agents](/docs/quickstart/) — the served-flow loop, for when you want Weir to
  render onboarding too.
- [Concepts](/docs/concepts/) — the instrumentation contract Observe shares, and the delivery
  model it does not use.
- [Monitoring](/docs/monitoring/) — operating a live flow: the funnel and health reads an Observe
  integration watches.
- [Why Observe](/docs/why-observe/) — the positioning, the non-goals, and the retained proof.
