Onboard an app
Permalink to Onboard an appThis 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| Host | v4 render path | Baseline shape | Status |
|---|---|---|---|
| Native iOS | WeirFlowView or Weir.present renders SwiftUI | A resource directory with config.json or flows/<flowId>.json, plus declared assets | Public Swift package exists; 1.0.1 telemetry is release-blocked as described above |
| React Native / Expo on iOS or Android | npm install weir-react-native (from 1.1.0; until then, vendor) | Directory containing config.json, plus declared assets | Public 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 / Compose | Public 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 assets | Public 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 immutablyIn 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 filesKeep these source-controlled in the app repository:
- a specVersion-4 flow config;
weir/components.jsonwhen 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 flowThese 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 --dryRunFor 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 baselinePackage the validated v4 JSON itself, not the browser preview output:
- SwiftUI: run
weir embed --appRoot . --flowId onboarding, then addApp/Resources/Weir/Onboardingto the app target as a folder reference. The installed app must containWeir/Onboarding/config.jsonand everycustomAssets[].pathfile without Xcode flattening the directory. The adapter below resolves that directory asconfigRootURL. - 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 type | Required Info.plist key(s) | Required even if unused? |
|---|---|---|
notifications | none | — |
tracking | NSUserTrackingUsageDescription | No |
camera | NSCameraUsageDescription | No |
location | NSLocationWhenInUseUsageDescription | No |
health | App-owned HealthKit provider and its matching purpose strings | No — 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 runtimeSwiftUI
Permalink to SwiftUIUse 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:
- Register every app-owned SwiftUI component.
- Install the generated manifest version/hash in
ComponentRegistry. - Configure
WeirUpdateConfigwith the full per-flow public manifest URL and Ed25519 public key. - 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. - Present
WeirFlowViewor callWeir.presentwith the baseline config root. - On
failedordismissed, 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 / ExpoHow 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 (seePACKAGING.mdin the SDK for the vendoring recipe, which stays supported afterwards). The publicweir-toolkitcovers everything up to this point (authoring, validation, preview, the localweir dashboardloop,components-sync, the release gate).
The host must:
- Register custom RN components and call
WeirRegistry.setGeneratedRegistry(...). - Call
fetchConfig(flowId, manifestURL, { appVersion, sdkVersion: SDK_VERSION })using the package's exportedSDK_VERSION, never a host literal. - Mount
<WeirFlow config={verifiedConfig} ...>with a stable user id, registry, event sink, purchase provider, and permission provider required by the flow. - 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.
- 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 liveAfter 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 proofRun 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:
| Dimension | Evidence |
|---|---|
| App version | Matches the built application's marketing version |
| SDK version | Comes from the SDK/package, not a copied host constant |
| Platform | ios for SwiftUI; reactNative for RN/Expo |
| Manifest version | Matches the generated registry source compiled into the app |
| Registry hash | Matches 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 telemetryThe 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:
- Embedded v4 config works offline.
- A compatible signed remote config is verified and promoted at the next presentation boundary.
- A corrupt signature/hash is rejected.
- An incompatible build/registry does not render the new config.
- Kill switch returns to app-owned onboarding.
- 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| Failure | Required user-visible result | Required evidence |
|---|---|---|
| Kill switch off or missing | App-owned native onboarding; no Weir fetch | Launch log plus device journey |
| No network / manifest timeout | Previously verified cache, otherwise embedded baseline; never blank UI | Relaunch offline and record bundle source |
| Invalid signature, hash, or malformed config | Reject candidate; keep cached/embedded config | Failure event plus unchanged active bundle |
| Build/component incompatibility | Older compatible signed version, otherwise cached/embedded baseline | servedForBuild query using the exact build/registry dimensions |
| Runtime decode/presentation failure | App-owned native onboarding | .failed handoff and fallback event |
| Ingest unavailable or rejects events | Keep events durably queued; onboarding remains usable | Queue retry plus response with rejected === 0 after recovery |
| Bad remote release | Forward rollback, then terminate/relaunch before retest | New version id whose bytes match the requested source version |
Package status
Permalink to Package statusThe 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.