Weir docsv4
View as Markdown

Onboard an app

Permalink to Onboard an app

This is the canonical integration checklist. Native iOS has an immutable public Swift package; Android Compose and React Native/Expo do not have public runtime packages and remain private design-partner paths. The public iOS tag 1.0.1 has a known telemetry defect: ingest payloads can be rejected while the HTTP request succeeds. Use it only to reproduce rendering and delivery. A newer fixed immutable tag must be published and pinned before production use; source-tree fixes are not a release.

Do not use a WebView, an HTML bundle, index.html, or customHtml for a new v4 integration. Those belong to the retired architecture.

Choose the runtime

Permalink to Choose the runtime
Hostv4 render pathBaseline shapeStatus
Native iOSWeirFlowView or Weir.present renders SwiftUIA resource directory with config.json or flows/<flowId>.json, plus declared assetsPublic Swift package exists; 1.0.1 telemetry is release-blocked as described above
React Native / Expo on iOS or Androidnpm install weir-react-native (from 1.1.0; until then, vendor)Directory containing config.json, plus declared assetsPublic npm package prepared at 1.1.0, publish pending the npm token. weir init still refuses by default until the toolkit's RN install path is updated; add the dependency by hand.
Native Android / ComposePublic JitPack artifacts com.github.cynisca.weir-sdk-android:weir:1.1.0 (Compose UI) and :weir-observe:1.1.0 (Compose-free)v4 config plus assetsPublic since 2026-09-07. weir init still refuses by default until the toolkit's Android install path is updated; add the JitPack dependency by hand.

For a private runtime contract, obtain the package from Weir first, then run ./node_modules/.bin/weir init with --acknowledgePrivateRuntime. The acknowledgement does not grant or download anything. There is no supported retired-WebView fallback.

Install the iOS SDK immutably

Permalink to Install the iOS SDK immutably

In Xcode, add this exact package dependency and select the umbrella product Weir for the app target:

.package(
    url: "https://github.com/cynisca/weir-sdk-ios.git",
    exact: "1.0.1"
)
// product: "Weir"

Do not use a branch or from: range for a delivery runtime. Because of the 1.0.1 telemetry defect above, production must wait for and pin a newer fixed tag after it is publicly published and independently resolved.

App-owned files

Permalink to App-owned files

Keep these source-controlled in the app repository:

  • a specVersion-4 flow config;
  • weir/components.json when the flow uses app-owned custom components;
  • the generated registry source returned by a real weir components-sync;
  • the same validated v4 config copied into the app's baseline resource directory;
  • an integration file that registers components, installs the generated registry, configures signed updates and ingest, presents/mounts the flow, and routes every failure to the app's own onboarding;
  • an app-owned kill switch whose local default is off.

weir/app.json is the repository contract used by toolkit commands. integrationFile belongs inside each flows[] object—not at the top level:

{
  "version": 1,
  "appId": "example-app",
  "appVersion": "1.0.0",
  "platform": "ios",
  "runtimeAccess": "public",
  "flows": [{
    "id": "onboarding",
    "spec": "weir/flows/onboarding.json",
    "placement": "first_launch",
    "baselineBundle": "App/Resources/Weir/Onboarding",
    "featureFlag": "weir_onboarding_enabled",
    "nativeFallback": "native_onboarding",
    "integrationFile": "App/Weir/WeirIntegration.swift"
  }],
  "delivery": {
    "manifestBaseURL": "https://delivery.example.com",
    "updateConfig": "remote-config"
  },
  "telemetry": {
    "appAnalyticsForwarding": true,
    "ingestConfiguredAtRuntime": true
  }
}

All paths are app-root-relative. Never put an ingest token or signing private key in this file. Run weir app-doctor --appRoot . after changes. A green result means sourceContractReady, not runtime or ship ready.

Author and validate the flow

Permalink to Author and validate the flow

These commands operate on the v4 spec and the browser-based authoring harness:

weir dev --specPath weir/flows/onboarding.json --serve
weir walk --specPath weir/flows/onboarding.json --outDir .eval-out/walk
weir release --appRoot . --flowId onboarding --dryRun

For supported SwiftUI and React Native app contracts, the dry run builds the browser artifact and runs weir conform internally. The preview is deliberately not the app renderer. Passing it proves schema, branches, variables, and the expected event contract; it does not prove SwiftUI/RN layout, native permissions, purchases, packaging, signed fetch, or fallback behavior. Use the app doctor plus platform-native tests and a controlled device walk for those claims.

Package the native baseline

Permalink to Package the native baseline

Package the validated v4 JSON itself, not the browser preview output:

  • SwiftUI: run weir embed --appRoot . --flowId onboarding, then add App/Resources/Weir/Onboarding to the app target as a folder reference. The installed app must contain Weir/Onboarding/config.json and every customAssets[].path file without Xcode flattening the directory. The adapter below resolves that directory as configRootURL.
  • React Native/Expo: point the package's baseline config plugin at a directory containing config.json. The plugin copies that directory into the native application and derives permission declarations from the embedded flow.

weir init scaffolds this native baseline, weir embed --flowId <id> refreshes it from the app contract, and weir_app_doctor checks config.json schema, flow identity, freshness, and referenced asset bytes. Browser preview output remains optional visual QA; it is not the v4 native baseline. A source-contract-ready doctor proves the repository contract is internally consistent, but a real app build and device journey must still prove the resources were packaged and rendered.

Purpose strings (iOS)

Permalink to Purpose strings (iOS)

A permissionPrime screen can prime notifications, tracking, camera, or location with Weir's built-in provider. On iOS, each relevant type has an app-owned Info.plist requirement only when the app can reach that permission request:

permissionPrime typeRequired Info.plist key(s)Required even if unused?
notificationsnone
trackingNSUserTrackingUsageDescriptionNo
cameraNSCameraUsageDescriptionNo
locationNSLocationWhenInUseUsageDescriptionNo
healthApp-owned HealthKit provider and its matching purpose stringsNo — Weir's default provider returns unavailable

WeirCore does not link HealthKit. If your product needs a health prompt, inject an app-owned PermissionProviding implementation, request only the HealthKit data types your product uses, and declare the matching purpose strings in your app. weir_app_doctor does not infer those app-specific requirements. Full API-to-key mapping is in the SDK README.

Wire the runtime

Permalink to Wire the runtime

SwiftUI

Permalink to SwiftUI

Use one long-lived integration object per app process. This minimal adapter compiles against the umbrella Weir product; substitute the real app id and analytics call. Runtime values come from app-owned build settings or remote config, with safe empty and default-off values:

import SwiftUI
import Weir

struct WeirRuntimeConfig {
    var enabled = false
    var manifestURL: URL?
    var manifestPublicKey = ""
    var ingestURL: URL?
    var ingestWriteToken = ""
}

final class HostAnalyticsSink: EventSink {
    let track: (EventParams) -> Void
    init(track: @escaping (EventParams) -> Void) { self.track = track }
    func append(_ event: EventParams) { track(event) }
}

@MainActor final class WeirIntegration {
    let userId = Weir.stableUserId()
    let eventQueue = EventQueue()
    let eventSink: EventSink
    let enabled: Bool

    init(config: WeirRuntimeConfig, track: @escaping (EventParams) -> Void) {
        enabled = config.enabled
        eventSink = CompositeEventSink(eventQueue, HostAnalyticsSink(track: track))
        guard enabled else { return }
        if let url = config.manifestURL, !config.manifestPublicKey.isEmpty {
            Weir.configure(
                updates: WeirUpdateConfig(manifestURL: url,
                                          publicKeyRawBase64: config.manifestPublicKey),
                eventSink: eventQueue
            )
        }
        if let url = config.ingestURL, !config.ingestWriteToken.isEmpty {
            eventQueue.configureIngest(WeirIngestConfig(
                endpointURL: url,
                appId: "example-app",
                writeToken: config.ingestWriteToken,
                deviceContext: .current(sdkVersion: WeirSDKVersion.current)
            ))
        }
    }

    static func baselineRoot(in bundle: Bundle = .main) -> URL? {
        bundle.url(forResource: "config", withExtension: "json",
                   subdirectory: "Weir/Onboarding")?.deletingLastPathComponent()
    }
}

Retain it and present only while its kill switch is enabled:

WeirFlowView(
    flowId: "onboarding",
    configRootURL: WeirIntegration.baselineRoot(),
    userId: integration.userId,
    eventSink: integration.eventSink
) { result in
    switch result {
    case .completed(let variables):
        integration.eventQueue.triggerFlush()
        completeNativeOnboarding(with: variables)
    case .dismissed, .failed:
        integration.eventQueue.triggerFlush()
        showNativeOnboarding()
    }
}

The host must, in this order:

  1. Register every app-owned SwiftUI component.
  2. Install the generated manifest version/hash in ComponentRegistry.
  3. Configure WeirUpdateConfig with the full per-flow public manifest URL and Ed25519 public key.
  4. Create a stable user id and durable EventQueue; configure ingest only when a real write token is available from the app's secret-bearing build/runtime configuration.
  5. Present WeirFlowView or call Weir.present with the baseline config root.
  6. On failed or dismissed, present the app's own native onboarding.

The stable user id controls assignment; the EventQueue owns durable delivery; the composite sink tees identical events to host analytics; runtime config owns updates, ingest, and the default-off kill switch. The SDK's fetch request includes app version, SDK version, platform, generated manifest version, and registry hash when the generated registry is installed. Never hard-code a copied SDK version in the host.

React Native / Expo

Permalink to React Native / Expo
How you get the RN runtime SDK. npm install weir-react-native — from 1.1.0. That version is prepared and publish is pending the npm token; until it lands on the registry, Weir vendors the built package into design-partner apps during onboarding (see PACKAGING.md in the SDK for the vendoring recipe, which stays supported afterwards). The public weir-toolkit covers everything up to this point (authoring, validation, preview, the local weir dashboard loop, components-sync, the release gate).

The host must:

  1. Register custom RN components and call WeirRegistry.setGeneratedRegistry(...).
  2. Call fetchConfig(flowId, manifestURL, { appVersion, sdkVersion: SDK_VERSION }) using the package's exported SDK_VERSION, never a host literal.
  3. Mount <WeirFlow config={verifiedConfig} ...> with a stable user id, registry, event sink, purchase provider, and permission provider required by the flow.
  4. Configure native durable ingest from native build metadata; do not expose its write token to JavaScript. For a production Expo build, set the plugin option <code>requireIngestToken: true</code> and provide <code>WEIR_DEMO_INGEST_TOKEN</code> only to the prebuild environment. The plugin fails prebuild if that required variable is absent, rather than shipping an artifact that only queues events. Then run <code>weir ship-ready</code> against the built artifact: it checks the generated <code>WeirIngestWriteToken</code> (iOS) or <code>weir.ingestWriteToken</code> (Android) entry without printing its value.
  5. Route absent native modules, failed fetches, decode/registry errors, dismissals, and runtime failures to the app's existing onboarding.

Expo Go cannot load the required third-party native module. Use a development or release build.

Add the runtime's Expo config plugin before prebuilding. It embeds the validated baseline config in the binary and supplies the pinned verify key to the native module. Use app.config.js (rather than copying the key into app.json) so the value always comes from the weir.config.json that weir init wrote:

// app.config.js
const app = require("./app.json");
const weirConfig = require("./weir.config.json");

module.exports = {
  expo: {
    ...app.expo,
    plugins: [
      ...(app.expo.plugins ?? []),
      ["weir-react-native", {
        baselineConfigPath: "weir/config/onboarding",
        weirPublicKeyBase64: weirConfig.publicKeyBase64,
      }],
    ],
  },
};

For a release build, add requireIngestToken: true to that plugin entry and provide WEIR_DEMO_INGEST_TOKEN only to the prebuild environment. The plugin never places that token in JavaScript.

The whole host integration is one file. weir init already wrote weir.config.json with this deployment's manifestUrl and pinned verify key, and weir components-sync already wrote weir/generated/WeirComponentRegistry.generated.ts — import both rather than retyping their values, so a re-init or a re-sync can never leave a stale literal behind:

// weir/WeirOnboarding.tsx
import { useEffect, useState } from "react";
import { fetchConfig, WeirFlow, WeirRegistry } from "weir-react-native";

import weirConfig from "../weir.config.json";
import { WeirComponentRegistry as GENERATED_REGISTRY } from "./generated/WeirComponentRegistry.generated";
import components from "../weir/components.json";
import Welcome from "../screens/Welcome";
import PickSet from "../screens/PickSet";
import Paywall from "../screens/Paywall";

// 1. Register each native screen under the name its components.json entry
//    declares, with that entry's own propsSchema — one source, no drift.
const SCREENS = { "cardclub.welcome": Welcome, "cardclub.pickSet": PickSet, "cardclub.paywall": Paywall };
for (const entry of components.components) {
  WeirRegistry.register(entry.component, SCREENS[entry.component], entry.propsSchema);
}
WeirRegistry.setGeneratedRegistry(GENERATED_REGISTRY);

/** Renders the published flow, or `fallback` if Weir can't serve one. */
export function WeirOnboarding({ appVersion, fallback, onComplete }) {
  const [state, setState] = useState({ kind: "loading" });

  useEffect(() => {
    // 2. sdkVersion is deliberately omitted — the package sends its own.
    fetchConfig(weirConfig.flowId, weirConfig.manifestUrl, { appVersion }).then((result) =>
      setState(result.ok ? { kind: "flow", result } : { kind: "fallback", reason: result.reason }),
    );
  }, [appVersion]);

  // 3. Every failure — no native module, no network, bad signature, an
  //    unregistered component — lands here, on the onboarding already in the
  //    binary. Weir being unreachable must never block a launch.
  if (state.kind !== "flow") return state.kind === "loading" ? null : fallback;

  return (
    <WeirFlow
      config={state.result.config}
      configRevision={state.result.configRevision ?? undefined}
      onComplete={onComplete}
      onDismiss={onComplete}
    />
  );
}

Make the live path actually live

Permalink to Make the live path actually live

After publishing, the app's normal onboarding branch must render <WeirOnboarding ...>. Do not guard it with a literal false or a permanently disabled local switch: that only proves the native fallback, never the signed published flow. A production kill switch can choose the branch, but its enabled value must be true for the deployment being onboarded. The fallback passed above remains the recovery path for failed fetches, bad signatures, registry failures, and render errors.

Each registered screen must also accept Weir's injected weir prop and use weir.submit() for its next action. Keep the original onNext as a default for the fallback path, for example:

export default function Welcome({ headline, subhead, ctaLabel, weir, onNext = () => {} }) {
  const next = () => weir ? weir.submit() : onNext();
  return <Pressable onPress={next}><Text>{ctaLabel}</Text></Pressable>;
}

A registered screen renders the props the flow gave it, plus one extra weir prop — the WeirCustomScreenApi above. Where the pre-Weir screen called its own onNext, it now calls weir.submit(); nothing else about the component changes:

export default function Welcome({ headline, subhead, ctaLabel, weir }) {
  return (
    <View>
      <Text>{headline}</Text>
      <Text>{subhead}</Text>
      <Pressable onPress={() => weir.submit()}><Text>{ctaLabel}</Text></Pressable>
    </View>
  );
}

Keep onNext as a fallback default (onNext = () => weir?.submit(), or the reverse) if the same component still has to render in the app's own pre-Weir path.

Component sync and compatibility proof

Permalink to Component sync and compatibility proof

Run weir components-sync --appRoot . against the target app and server. Commit the generated registry file. Then prove all five fetch dimensions from an actual app request:

DimensionEvidence
App versionMatches the built application's marketing version
SDK versionComes from the SDK/package, not a copied host constant
Platformios for SwiftUI; reactNative for RN/Expo
Manifest versionMatches the generated registry source compiled into the app
Registry hashMatches the server's stored copy for that exact manifest version

Publish a config with a custom component/prop mismatch and prove the server refuses it. Request a valid later config from an older build and prove the server serves an older compatible version or the client falls through to its app-embedded baseline.

After publishing, weir app-status deliberately separates globalLatestManifest from servedForBuild. Global latest is publication history, not evidence for a device. Pass the real SDK version and, for custom components, the generated registry pair:

weir app-status --appRoot . --sdkVersion 1.0.1 \
  --registryManifestVersion 7 --registryHash <64-lowercase-hex-from-generated-registry>

The command takes app version and platform from weir/app.json. If any build dimension is missing, servedForBuild.status is explicitly unknown; it never relabels global latest as served. Use the SDK version actually compiled into the build, not the example above.

Kill switch and telemetry

Permalink to Kill switch and telemetry

The app-owned feature flag must default to off. Missing config, timeout, invalid URL/key, or a failed remote fetch must preserve the app's normal onboarding. Keep the public manifest URL/key separate from the secret ingest write token. Flipping the flag off must return to the app's own onboarding without contacting Weir.

Before calling the integration complete, demonstrate from a real build:

  1. Embedded v4 config works offline.
  2. A compatible signed remote config is verified and promoted at the next presentation boundary.
  3. A corrupt signature/hash is rejected.
  4. An incompatible build/registry does not render the new config.
  5. Kill switch returns to app-owned onboarding.
  6. A real device event reaches the read API with the expected app/SDK/flow version.

Remote delivery is presentation-boundary safe: a valid signed response is downloaded into staging, verified, then promoted atomically. It does not replace a flow already on screen. Publish, wait for the manifest request to complete, terminate and relaunch the test app, then begin a new onboarding presentation and verify the served bundle id. Rollback is also a new forward version: relaunch again before expecting the restored bytes.

For telemetry, HTTP 200 alone is not success. Inspect the ingest response and require accepted > 0 and rejected === 0; any nonzero rejected count is a failed integration even when the request itself succeeded. Then use weir events --flow onboarding and weir app-status --appRoot . --sdkVersion <compiled-x.y.z> to prove readback. Never print the write token while collecting this evidence. Do not guess undocumented local HTTP read endpoints: use the documented CLI commands for any additional readback.

Safe recovery matrix

Permalink to Safe recovery matrix
FailureRequired user-visible resultRequired evidence
Kill switch off or missingApp-owned native onboarding; no Weir fetchLaunch log plus device journey
No network / manifest timeoutPreviously verified cache, otherwise embedded baseline; never blank UIRelaunch offline and record bundle source
Invalid signature, hash, or malformed configReject candidate; keep cached/embedded configFailure event plus unchanged active bundle
Build/component incompatibilityOlder compatible signed version, otherwise cached/embedded baselineservedForBuild query using the exact build/registry dimensions
Runtime decode/presentation failureApp-owned native onboarding.failed handoff and fallback event
Ingest unavailable or rejects eventsKeep events durably queued; onboarding remains usableQueue retry plus response with rejected === 0 after recovery
Bad remote releaseForward rollback, then terminate/relaunch before retestNew version id whose bytes match the requested source version

Package status

Permalink to Package status

The official iOS repository is https://github.com/cynisca/weir-sdk-ios.git; its current public immutable tag is 1.0.1 and its SwiftUI umbrella product is Weir. That tag has the telemetry release blocker described above. A fixed public tag, publication verification, and an external clean-checkout/device pass remain founder-owned release gates. Android Compose and React Native do not have public runtime coordinates.

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