# Weir **Onboarding as code. Shipped without another app build.** Coding agents author specVersion-4 onboarding flows as JSON config in the app's own repository. The SDK renders those flows with stock components and app-owned custom components. Compatible copy, ordering, theme, and flow changes can be delivered to an already-installed app; adding a new native component type still requires an app release. Signed delivery, component-registry and app version gates, an embedded baseline, and a kill switch are designed to keep an incompatible remote config from replacing the fallback shipped with the app. Weir also defines the event contract and experiment surface underneath the flow. The v4 API, delivery path, and dashboard are live for a controlled private alpha. Public health reports `specVersion: 4` and identifies the deployed release artifact by commit and release version. That traceability is alpha operating evidence; do not read this as production-proof, broad self-service availability, or a conversion claim. This site is written for the agent doing that authoring — Claude, Cursor, or anything else working inside an app repo. Almost everything here has a companion tool call. The one exception is a human reviewing an already-published flow and leaving comments — see [Review a flow](/docs/review/). If you are an LLM reading this page, also fetch [`/docs/llms-full.txt`](/docs/llms-full.txt) for the entire site concatenated as clean markdown. ## Why the repository workflow matters The flow spec and app-owned component contracts live beside application code, so an agent can author them, validate them, and put them through normal source review. The MCP toolkit, CLI, and skill described on this site are the authoring surface; the dashboard operates the published-flow lifecycle (publish/rollback, experiments) and review. That workflow is not based on competitors lacking agent tools. Superwall, for example, publicly documents an account-management MCP, an editor MCP, a skill, CLI tooling, and onboarding Flows. Weir's intended distinction is narrower: composing the app's own registered native components through a repository-owned flow contract, with compatibility gates and an app-shipped fallback. See [Superwall's MCP documentation](https://superwall.com/docs/dashboard/guides/superwall-mcp) and [onboarding Flows documentation](https://superwall.com/docs/dashboard/guides/using-superwall-for-onboarding-flows) for the capabilities that comparison acknowledges, and [the native component model](/docs/concepts/#the-native-component-model) for Weir's product model. ## The 30-second loop 1. **Author** a flow spec — JSON describing screens, branching, and variables — in the app's own repo, under `weir/flows/`. If it uses any custom native components, register them once with [`weir components sync`](/docs/concepts/#custom-components-and-the-component-manifest). 2. **`weir release`** — validates, runs the contract gate, and publishes the signed config to the configured delivery origin. 3. **A compatible installed app can update.** Its update check fetches and verifies the config, then promotes it only when the app, SDK, spec, and registered-component gates match. New native component types still ship in an app build. ``` edit weir/flows/onboarding.json -> weir release --appRoot . --flowId onboarding -> relaunch the already-installed app -> new onboarding renders; the event lands in weir funnel ``` ## Scope, honestly Onboarding flows only, today. To be precise about what that means: a flow *can* contain a `paywall` screen, and the iOS SDK ships a working StoreKit 2 purchase client so that screen's CTA completes real purchases inside the onboarding flow. What Weir does **not** do yet is standalone paywall *management* — placements, audience targeting, paywall-only experiments, sheet presentation outside a flow. That layer stays on a dedicated paywall tool (e.g. Superwall) until Weir builds it. **Platforms, honestly:** the v4 SwiftUI implementation for iOS and the React Native/Expo implementation for iOS and Android both exist, including the RN native module and Expo config plugin, and are integrated into the two dogfood applications. Qualified SwiftUI and React Native dogfood evidence exists, but public package/reproducibility and the final evidence matrix remain release gates, so neither path is presented here as production proof. The Kotlin/Compose renderer also exists, with automated implementation evidence; its final device journey and immutable public distribution are incomplete, so it is not a supported public platform yet. The retired WebView delivery architecture is historical and is not the v4 product described here. ## Where to go - **Setting up the toolkit?** [Install the Weir skill / MCP server](/docs/install/) registers the `weir_*` tools in your agent host. - **Want no account and no server URLs?** [Run everything locally](/docs/local/) is the whole loop on one machine. - **New to an app?** Start at [Quickstart](/docs/quickstart/). - **Know the tool/node/event name you need?** [Reference](/docs/reference/) is one page, deep-linkable. - **Want the delivery/signing/kill-switch model?** [Concepts](/docs/concepts/). - **Operating a live flow?** [Monitoring](/docs/monitoring/) and [Experiments](/docs/experiments/). - **Checking exactly what is proven today?** [Evidence status](/docs/evidence/) distinguishes the public local loop, qualified dogfood evidence, and the release gates that remain. - **Reviewing a flow before or after it ships?** [Review a flow](/docs/review/) — the one page in this site for a human, not an agent. - **An agent that just wants the whole site as text?** [`llms.txt`](/docs/llms.txt) / [`llms-full.txt`](/docs/llms-full.txt). --- # Evidence status Weir is a controlled private alpha. This page separates what a visitor can run or inspect today from what still needs released-build or production evidence. It is not a case-study page and it makes no conversion, adoption, uptime, or production-maturity claim. ## Current proof ladder | Capability | Current status | What it proves | What it does not prove | |---|---|---|---| | Local author → conform → preview/release rehearsal | **Publicly runnable** | A developer can use the toolkit without an account, API key, or hosted service. | A hosted rollout reached a released customer build. | | SwiftUI custom-component integration | **Qualified dogfood evidence** | The v4 model has been integrated into a dogfood SwiftUI app. | Public package reproducibility, external-customer adoption, or an outcome result. | | React Native/Expo integration | **Qualified dogfood evidence** | The v4 model has been integrated into a dogfood React Native/Expo app. | Complete Android device proof or a public production claim. | | Browser review, generated config diff, and conformance findings | **Inspectable demo data** | The dashboard can demonstrate the review model without an account. | Native-device pairing or a released-build result; demo data is never a customer account. | | Compatible flow delivery to released builds | **Verification gate** | The product contract and local rehearsal define the intended path. | A public released-build demonstration until its evidence manifest is approved. | | Incompatible-build refusal and retained baseline | **Verification gate** | The app-owned baseline and registry/version gates are the designed safety model. | A public observed refusal on a released build until captured and approved. | | Decision-grade experiment outcome | **Pending sufficient traffic** | The event and assignment contract is available for the experiment lifecycle. | Conversion lift, a winning treatment, or general business impact. | ## Inspect the current loop 1. **Run it locally.** [Run everything locally](/docs/local/) for the no-account author → gate → local delivery rehearsal. Start with `weir audit --appRoot .` when you want an inventory of the declared flows, registered components, baseline, and readiness checks. The report labels its own limits: it does not infer active-install coverage or claim device or outcome proof. That local path remains the strongest public proof today. 2. **Inspect the fixture-backed product tour.** The [dashboard demo](https://weir-onboarding-2026.web.app/?demo=1#/demo) labels its data before showing a draft diff, conformance findings, rendered review, a compatibility refusal, and an experiment readout. It is an interface demonstration, not customer evidence. 3. **Read the delivery model.** [Concepts](/docs/concepts/) explains the app-owned component contract, signed delivery, embedded baseline, and kill switch. ## What a public proof recording must contain The next public proof asset must show the following in one continuous, attributable sequence: 1. An agent edits a repository-owned flow. 2. The generated review artifact identifies the changed journey and affected components. 3. The gate reports the compatible and incompatible build decisions. 4. An already-installed compatible build receives the release without a new binary install. 5. An incompatible build refuses the remote flow and retains its embedded baseline. 6. Assignment, flow version, build version, step progress, and outcome arrive in the evidence surface. Until that recording is captured with an approved evidence manifest, the homepage and docs must describe it as a gate, not an observed public result. ## Claims boundary Use these phrases precisely: - **Compatible composition changes can be delivered without a new app build.** New component capabilities still require an app release. - **Your app owns rendering, interaction, and product logic.** Weir composes registered screens and properties; it is not a raw-markup or visual-editor runtime. - **The v4 API, flow delivery path, and dashboard are live for a controlled private alpha.** Do not call Weir production-proven, a public beta, or self-serve hosted software yet. For how that boundary applies during an integration, see [Onboard an app](/docs/onboard-an-app/) and [Review a flow](/docs/review/). --- # You already have analytics. You still don't know where onboarding breaks. You have an onboarding flow and you have a general-purpose analytics SDK. Between them you can tell roughly how many people finish, as long as somebody remembered to fire the right event on the right screen, and nobody has renamed one since. Observe is that, without the maintenance. You name your onboarding screens once, in order, and every impression, exit, dwell time and paywall view lands against that list. Drop-off is reported per screen, not per event name someone invented in a hurry. Your onboarding stays exactly as you built it. Observe adds one dependency and reads it. ## What comes back - Sessions and drop-off at every screen, in your declared order. - Median dwell per screen, timed by the SDK, not by you. - Completion rate, paywall reach rate, and trial start rate. - The same reads across app versions, so a release is a cohort. - A trust verdict on the pipeline itself. An app with no events reads `no_data`, and `no_data` is never rendered as green. That last one is the difference from an event bus. A general analytics SDK will keep showing you a funnel after the events stop arriving, and the funnel will look like a drop in conversion. ## The whole integration Three calls. Everything else on the surface is optional. ```swift import WeirObserve 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() } ``` Call `observe` once at launch, `screen` once per screen, `completed` where onboarding ends. Android and React Native expose the same shape. The step-by-step version is the [Observe quickstart](/docs/observe/). The screen order is declared once and never passed per call. A hand-typed index is a second source of truth that drifts from the real order, and the funnel is ordered by that index. A screen name that is not in the declared array is recorded as undeclared and turns the app amber. It is never absorbed into the funnel silently. ## Two claims, and how to check them Both are internal verification runs on a Mac mini, against native Release builds. They are not customer results. Weir is a controlled private alpha — see [Evidence status](/docs/evidence/) for the full boundary. ### About fifteen minutes to integrate The bar was fifteen agent minutes. Three consecutive cold runs came in at 86, 56 and 67 seconds. Each run gave a fresh agent nothing but the public documentation and a sample app with no Weir code in it. The run sandbox denies this repository, so an agent that consults the source fails the run outright. Each agent added the calls itself, produced an iOS `Release` build, drove five onboarding screens under Maestro, and rendered a funnel in the dashboard. Retained at `demo/runs/rfc015-observe/rfc015-observe-20260901-010540Z-89978/` and its two siblings under `demo/runs/rfc015-observe/`. Each summary ends `RFC-015 COLD OBSERVE PASS 9/9`. ### The numbers are correct, and the check is not ours to grade A Release build was driven through 30 scripted device sessions: 12 completions, 10 abandons on screen 3, 5 on screen 4, 3 reaching the paywall. The runner computed ground truth. The dashboard was then read in a real browser and compared against it. | Read | Ground truth | Rendered | |---|---|---| | Sessions per step | 30, 30, 30, 20, 15 | 30, 30, 30, 20, 15 | | Drop-off per step | 0, 0, 0, 0.333, 0.25 | 0, 0, 0, 0.333, 0.25 | | Completion rate | 0.4 | 40.0% | | Paywall reach | 0.5 | 50.0% | | Trial start | 0.1 | 10.0% | Device dwell medians landed within 33 ms of the scripted sleeps, against a tolerance of 150 ms. Retained at `demo/runs/rfc015-observe/rfc015-observe-dashboard-funnel-20260903-052012Z/`, with the source device run at `docs/verification/rfc015-observe-device-funnel-2026-09-01.txt`. Three failure modes were induced rather than asserted: - **A misspelled screen name.** A build emitted `experiance` while declaring `experience`. One impression in 20 was flagged undeclared, trust went amber, and the dashboard labelled the step `Undeclared` instead of reordering the funnel. (`demo/runs/rfc015-observe/rfc015-observe-device-undeclared-20260903-050500Z/`) - **A kill mid-flow and a 609-second network blackhole.** One session survived across two launches with contiguous sequence numbers and no duplicates. On recovery the queue drained to zero with no eviction: 18 events enqueued, 18 uploaded. (`demo/runs/rfc015-observe/rfc015-observe-loss-dedupe-20260903-045133Z/`) - **An app with no data at all.** A registered Observe app with an empty events table reported `no_data` on the home read, the Overview, and the funnel page. It never reported green. (`demo/runs/rfc015-observe/rfc015-observe-configless-20260903-045500Z/`) ## What Observe does not do - **It does not rewrite your onboarding.** No flow spec, no served config, no screens of ours in your app. `WeirObserve` depends on `WeirCore` only and never links the UI target. - **It does not open a WebView.** There is no web runtime anywhere in the integration. - **It does not fetch configuration or ship anything over the air.** Serving is a separate opt-in product, and adopting Observe does not adopt it. - **It does not run experiments.** No variant assignment on an Observe app until you ask for it. - **It does not replace your analytics.** Observe measures one flow. Keep your event bus for everything after onboarding. - **It does not claim revenue.** `purchase_result` is a funnel marker, never a financial source of truth. `userId` is an opaque string you supply; the SDK invents no identifiers. ## Next - [Observe quickstart](/docs/observe/) — the iOS, Android, and React Native integration, step by step. - [Evidence status](/docs/evidence/) — what Weir does and does not claim today. - [Monitoring](/docs/monitoring/) — the funnel and health reads an Observe integration watches. --- # Install the Weir skill / MCP server Weir's product surface is a set of `weir_*` tools an agent calls — not a GUI. This page registers those tools in your agent host and verifies they loaded. Do this once per workspace, before [Quickstart](/docs/quickstart/). ## Install The toolkit ships as the npm package **`weir-toolkit`**. No account, no API key, no repo access: ```bash npm install -g weir-toolkit ``` > **Note:** These docs describe the v4 toolkit, which needs `weir-toolkit >= 0.4.4` (the current > published release). Run `weir --version` to check your installed version; do not copy a > changing "latest" number from prose. The 0.4.0 release > added `weir experiment-ship-winner` and the `weir components-sync --check` preflight; a toolkit > older than 0.4.0 cannot run them. 0.4.5 is pending publish and will fix the package's bin alias > so that `npx -y weir-toolkit init` works without the `-p` flag. If validation demands > `specVersion: 3` or rejects `specVersion: 4` or a `custom` screen type, your toolkit is pre-v4. > Upgrade it with `npm install -g weir-toolkit@latest`. Or run it ad hoc with npx (no global install): `npx -y -p weir-toolkit weir init` is the canonical cold-start command for one-off verbs. From weir-toolkit 0.4.5 onward, `npx -y weir-toolkit init` works directly (no `-p` flag needed) once the bin-alias fix (roadmap 6.2) is published. The package provides two commands: `weir` (the CLI — every tool as a subcommand) and `weir-mcp` (the MCP server — the identical tools over stdio for an agent host). The full tool list is on [Reference → MCP tools](/docs/reference/#mcp-tools-cli-verbs). `weir walk`, `weir conform`, and `weir release` drive a real headless Chromium via Playwright — install the browser binary once: ```bash npx playwright install chromium ``` ## Register the MCP server ### Claude Code / any `.mcp.json` host Add a server entry to `.mcp.json` at the workspace root: ```json { "mcpServers": { "weir": { "command": "npx", "args": ["-y", "-p", "weir-toolkit@latest", "weir-mcp"] } } } ``` Restart the agent host so it re-reads `.mcp.json`. The `-p` flag is required — the package has two executables, and `npx` needs it to resolve `weir-mcp`. ### Cursor / other MCP clients Use the client's MCP-server config UI or file: command `npx`, args `["-y", "-p", "weir-toolkit@latest", "weir-mcp"]`. The transport is stdio. ## Verify the toolset loaded Ask the host to list its tools. You must see the `weir_*` tools (`weir_dev`, `weir_walk`, `weir_conform`, `weir_embed`, `weir_release`, and the rest). If the list is empty, the server did not register — run `npx -y -p weir-toolkit@latest weir-mcp` by hand; it should sit silently waiting for a client (Ctrl-C to stop). If it errors instead, that error is the problem. The CLI gives the same check without an agent host: ``` weir list ``` This prints every subcommand with its one-line description, straight from each tool's own `description` field. If you see the verb list (`dev`, `walk`, `conform`, `embed`, `release`, …), the toolkit is installed and current. That list is generated from the same source as the MCP tools, so it can never drift from what the server exposes. ## Use the CLI as the fallback surface Every MCP tool maps to one CLI verb: the tool name with the `weir_` prefix stripped and underscores turned to dashes (`weir_app_status` becomes `app-status`). Run any verb like this: ``` weir --fieldName value ``` Flags are `--fieldName value`, parsed against the same schema as the MCP tool. Array fields need a repeated flag, not space-separated values — pass `--personas "" --personas "purchase=cancel"`, not `--personas "" "purchase=cancel"`. Exit code is 0 on success, 1 on a tool error, so the CLI is safe in CI and `&&` chains. ## React Native / Expo > **Warning:** Expo Go cannot load the required third-party native module. Use a development or release build. ## Next - Scaffold a new app in one command: [weir init — scaffold a new app](/docs/init/). - Author your first flow spec: [Author your first flow](/docs/first-flow/). - Run the whole loop with no account and no server URLs: [Run everything locally](/docs/local/). - The full command loop: [Quickstart](/docs/quickstart/). --- # 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 . 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 ``, 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. --- # weir init — scaffold a new app in under a minute `weir init` is the first command a new integration runs: give it an app id and a platform, and it writes a valid `weir/app.json`, a working starter flow, and the flow's built baseline bundle. For a delivery-backed integration, first export the supplied delivery origin and app token: init uses them to write `weir.config.json` with that origin's manifest URL and pinned public key. Run it right after [installing the toolkit](/docs/install/), before you hand-author anything. It reuses `weir_app_scaffold`'s scaffold logic exactly (same files, same doctor-ready guarantee) and is registered under both names; `weir init` is the discoverable "first five minutes" name. ## Invocation ### Delivery-backed app (React Native/Expo and production-like local stacks) Export these values **before the first init**, then run init once. They are deliberately environment variables rather than flow fields, so they cannot be committed with the app configuration: ```sh export WEIR_API_URL="" export WEIR_API_TOKEN="" ./node_modules/.bin/weir init --appId my-app --platform reactNative --appRoot . --acknowledgePrivateRuntime ``` For a delivered/private toolkit, invoke **only** the binary in this app's installed kit: `./node_modules/.bin/weir`. Do not use a bare `weir` command from your shell PATH; a globally installed toolkit can be older or a different build and may silently write a different scaffold. The rest of this page uses `weir` as shorthand for that local binary. Confirm that both `weir/app.json` and `weir.config.json` now exist before editing the starter flow. Do not run `weir init --force` after beginning integration: it overwrites the scaffold and starter flow. If the first init was missing its delivery settings, restore the clean app checkout, export both values, then run the single normal init again. ### Offline scaffold For a source-only offline scaffold with no delivery origin, use the normal invocation below. It does not create a deployment-pinned `weir.config.json`; add delivery credentials before a real publish. As an MCP tool (from an agent host with the Weir MCP server registered — see [Install the toolkit](/docs/install/)): ``` weir_init({ appId: "my-app", platform: "ios" }) ``` As the CLI (identical behavior, same field names): ``` weir init --appId my-app --platform ios ``` ## Arguments | Field | Required | Default | Meaning | |---|---|---|---| | `appId` | yes | — | New app id. Used in `weir/app.json`, the feature-flag name, and (sanitized) the starter paywall's product id. | | `appRoot` | no | current working directory | Root directory of the host app to scaffold `weir/` into. | | `platform` | no | `"ios"` | `"ios"`, `"android"`, or `"reactNative"`. Only iOS has a public runtime; the other values are private design-partner contracts. | | `appVersion` | no | `"1.0.0"` | The host app's semantic marketing version in exact `x.y.z` form, used for compatibility status. | | `acknowledgePrivateRuntime` | no | `false` | Required for Android or React Native. Confirms you already understand that no public runtime can be acquired; it does not install or grant the private runtime. | | `flowId` | no | `"onboarding"` | Id for the starter flow; also its spec file name (`weir/flows/.json`). | | `placement` | no | `"launch"` | Where this flow is presented from, e.g. `"launch"` or `"verdict_ready"`. | | `force` | no | `false` | Overwrite an existing `weir/app.json` and starter flow spec. Without it, `weir init` refuses to run a second time over an existing scaffold. | ## What it writes ``` / weir/ app.json # complete, valid weir/app.json — parses against weirAppConfigSchema flows/ onboarding.json # starter specVersion-4 flow: welcome -> paywall (stock screen types only) config/ onboarding/ config.json # native v4 baseline config embedded by the host app ``` The starter flow already passes `@x/spec`'s `validate()`/`normalizeFlow()` and the full `weir_conform` gate (a persona walk reaches `complete()`, the event stream matches the manifest, perf budgets and native-feel checks hold). So `weir_app_doctor`, run immediately after `weir init`, reports `sourceContractReady: true` with `readinessKind: "source-contract-only"` and zero failures. This is not a runtime or shipping verdict. No `integrationFile` is scaffolded — wiring `Weir.configure`/`present` into the host app is deliberately left to you — so the doctor's `native-adapter` check only warns ("integrationFile not declared") until you add one. ## Output and next steps `weir init` prints a JSON result with `scaffolded: true`, the resolved `appId`/`platform`/ `flowId`, the paths it wrote, and a `next` array of agent-legible next steps: 1. **Wire the native SDK.** Add `integrationFile` inside that flow object in `weir/app.json` once you call the printed platform-specific SDK API; the doctor's `sdk-use` check fails the source contract if that file exists but never calls the SDK. 2. **Validate the contract:** `weir app-doctor --appRoot `. 3. **Optional browser QA only:** `weir dev --specPath /weir/flows/.json --serve`. This previews mocked native behavior; it does not validate native embedding. 4. **Learn the flow vocabulary before editing:** `weir schema` for all screen types and flow fields, or `weir schema ` for one type. 5. **Replace the starter screens.** Edit `weir/flows/.json`, rebuild `weir/config//config.json` from the normalized v4 spec, then re-run `weir app-doctor`. 6. **Test the publish path with no account and no network:** `weir release --appRoot --flowId --dryRun` (builds once, runs the full conform gate, never calls any API). 7. **View the flow in a real local dashboard:** `weir dashboard` starts a dev-mode local API and dashboard UI, signs you in automatically, and prints the URL to open. It requires Node.js 22+; other toolkit commands work on Node 18+. 8. **Publish for real** once you have a token: get one from the Weir dashboard, set `WEIR_API_TOKEN`, then run `weir release --appRoot --flowId ` (drop `--dryRun`). ## Re-running `weir init` refuses to overwrite an existing `weir/app.json` or starter flow spec unless `force: true` is passed — safe to run once per app id without silently clobbering hand-edited files. ## See also - [Install the Weir skill / MCP server](/docs/install/) — register the tools first. - [Onboard an app](/docs/onboard-an-app/) — the full `weir/app.json` contract, monorepo `--appRoot` conventions, and Android SDK/wiring details. - [Run everything locally](/docs/local/) — why every step above works with zero network and zero account. --- # Quickstart for agents This is the loop: onboard the app once, author a flow, verify it, ship it. Every step below is a real MCP tool or CLI verb — full field-level detail is in [Reference](/docs/reference/). > **Note:** These docs describe the v4 toolkit, which needs `weir-toolkit >= 0.4.4` (the current > published release). Run `weir --version` to check the installed version; do not copy a > changing "latest" number from prose. The 0.4.0 release > added `weir experiment-ship-winner` and the `weir components-sync --check` preflight; a toolkit > older than 0.4.0 cannot run them. 0.4.5 is pending publish and will fix the package's bin alias > so that `npx -y weir-toolkit init` works without the `-p` flag. If validation demands > `specVersion: 3` or rejects `specVersion: 4` or a `custom` screen type, your toolkit is pre-v4. > Upgrade it with `npm install -g weir-toolkit@latest`. ## 1. Onboard the app Run `weir init --appId --platform ios`. This offline command writes a valid `weir/app.json`, a starter flow, and its built baseline bundle. See [`weir init`](/docs/init/) for its arguments and sample output. Every host app owns a versioned `weir/app.json`. It is the source of truth for its app id, flows, baseline bundle locations, feature flag, native fallback, and telemetry intent. It deliberately holds **no secrets**; signing keys and ingest tokens stay in deployment config. `platform` is `"ios" | "android" | "reactNative"` and is required. Only iOS has a public runtime acquisition path. Android Compose and React Native/Expo are private design-partner runtimes: init refuses them by default. After Weir has granted runtime access, an integrator may explicitly pass `--acknowledgePrivateRuntime`; that acknowledgement scaffolds the contract but does not install the runtime. Do not treat either path as self-service. `weir init` generates an app config with this shape. The example shows why the generated config uses these fields: ```json { "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": { "updateConfig": "remote-config" }, "telemetry": { "appAnalyticsForwarding": true, "ingestConfiguredAtRuntime": true } } ``` **Validation rules** (`weirAppConfigSchema`, the toolkit's app-config validator) — the shape a schema enforces beyond what the one example JSON shows, so adapting it (not just copying it) does not trip an opaque Zod error: | Field | Rule | |---|---| | `version` | must be exactly `1` (the literal, not any other number) | | `appId` | non-empty string | | `appVersion` | optional semantic marketing version in exact `x.y.z` form; generated configs include it | | `platform` | one of exactly `"ios"` | `"android"` | `"reactNative"` | | `runtimeAccess` | optional `"public"` or `"design-partner"`; Android/RN doctor checks require the latter and still warn that package acquisition is unproven | | `flows` | array, **must be non-empty** — at least one flow is required | | `flows[].id`, `.spec`, `.placement`, `.baselineBundle`, `.featureFlag`, `.nativeFallback` | all non-empty strings, all required | | `flows[].integrationFile` | non-empty string, but **optional** | | `delivery.manifestBaseURL` | optional, but if present **must be a valid URL** (`z.string().url()`) — never include a signing private key here | | `delivery.updateConfig` | one of exactly `"build-settings"` | `"remote-config"` | `"debug-launch-arguments"` | `"none"` (four values only — no underscore variant like `"remote_config"`); defaults to `"build-settings"` if `delivery` is omitted entirely | | `telemetry.appAnalyticsForwarding`, `.ingestConfiguredAtRuntime` | booleans, both default `true` if `telemetry` is omitted | A validation failure reports as `invalid : : ` for every failing field at once (not just the first) — the `field` there is the dotted path from the table above. Use `weir init` for a new integration. Hand-author `weir/app.json` only when your existing app structure does not fit its scaffold. If you do, adapt the example for your app: its `appId`, flow `spec` path, `baselineBundle` location, `featureFlag`, and `nativeFallback`. Then validate it any time with: ``` weir app-doctor --appRoot . ``` `weir_app_doctor` returns `readinessKind: "source-contract-only"` and `sourceContractReady`. It checks each flow spec, its embedded baseline bundle, the declared Swift integration file, the delivery-mode declaration, and telemetry/fallback config — read-only, never writes or reads secrets. Its legacy `ready` alias is not a runtime or ship-ready claim; only a built artifact checked by `weir ship-ready` plus a device journey can establish that. ## 2. Author a flow spec A flow spec is JSON: a `theme`, a list of typed `variables`, and a list of `screens` — 13 stock types plus `custom` (see [Reference → Node types](/docs/reference/#node-types)), each with an `id` and an optional `next` branch. Screens read/write variables; later screens interpolate them with `{{variableId}}`. Put it at the path your app's `weir/app.json` declares (`weir/flows/.json`). `specVersion` must be exactly `4`. If any screen is `type: "custom"`, register that component once with the app before authoring against it — see [`weir components sync`](/docs/concepts/#custom-components-and-the-component-manifest). A flow built only from the 13 stock types needs no sync step at all. ## 3. Preview it ``` weir dev --specPath weir/flows/onboarding.json --serve ``` `weir_dev` serves the spec in-browser with mocked native behavior — fake permission/purchase dialogs, a mock localized product catalog, safe-area/notch overlay, keyboard-inset simulation — for every **stock** screen type. A `custom` screen has no browser preview (there is no way to preview a native SwiftUI view in a browser); `weir dev` shows a placeholder for it instead. Pass `--persona "notifications=deny,purchase=cancel"` to force a specific outcome while you look at it. > **Warning:** Expo Go cannot load the required third-party native module. Use a development or release build. ## 4. Walk every branch, headlessly > **One-time prerequisite:** `weir dev` (step 3) needs no browser, but `weir walk`, `weir conform`, > and `weir release` drive a real headless Chromium. Install it once before the first walk: > > ```bash > npx playwright install chromium > ``` ``` weir walk --specPath weir/flows/onboarding.json --outDir .eval-out/walk ``` `weir_walk` drives the same mocked preview for one or more persona configs, screenshotting every screen each persona's path visits, and writes a `walk-report.json` (path, native-call events, final variables) per persona. This is how you verify branches and personalization actually resolve — **read the screenshots**, don't just check the tool exited 0. ## 5. Build the embedded (offline) baseline ``` weir embed --appRoot . --flowId onboarding weir conform --bundleDir .eval-out/mcp/embed-build/onboarding ``` This is a separate artifact from the remote publish path in step 6, not a prerequisite for it — `weir embed` builds the flow into the app's own resources as an offline fallback (see [Concepts → Embedded baseline config](/docs/concepts/#embedded-baseline-config)), and `weir_release` builds and gates its own copy internally when you ship remotely. Build this whenever you want that fallback current, not on every iteration. Pass `--flowId` (the flow `id` from `weir/app.json`, matching the example above), not `--appResourcesDir`/`--bundleName`: `weir_embed` then reads that flow's `baselineBundle` (and `spec`) straight out of `weir/app.json` and copies the built bundle exactly there, so there is nothing to keep in sync by hand. It also prints the exact scratch build directory to pass to `weir_conform`. `weir_conform` runs the full contract eval against that **built bundle directory**: a persona walk reaches `complete()`, the auto-emitted event stream matches the manifest byte-for-byte, every declared variable gets set, purchase/permission calls only go through the native call layer (never a literal currency string), no network on the paint path, and perf budgets hold. `weir_release` runs this exact gate for you before it publish (next step) — running it here separately is for proving the offline fallback itself works, not a required step before shipping. ## 6. Ship it Validate the whole gate **fully offline first**, before any credential setup: ``` weir release --appRoot . --flowId onboarding --dryRun ``` `--dryRun` resolves the flow from `weir/app.json`, runs the full conform gate, and stops there — every step except the call to the delivery API. It needs no `WEIR_API_URL`/`WEIR_API_TOKEN`, so run it before anything else in this step: it surfaces conform problems (a broken flow spec, a failed persona walk, a perf-budget breach) separately from credential problems, so you fix one kind of blocker at a time. Only when the dry run is green do you need a reachable server and a token. For a real release, set the delivery origin and app token before the non-dry-run command: export WEIR_API_URL="https://your-weir-delivery-origin" export WEIR_API_TOKEN="" Keep both outside weir/app.json and out of source control. WEIR_API_URL defaults to http://localhost:8787 for a local API. WEIR_API_TOKEN may be omitted only when that local server has no token authentication configured; a production service with WEIR_API_TOKENS requires the token matching this app's appId. If an operator has not issued one yet, stop after the dry run and request it — the current server configuration is the token-issuance authority. ``` weir release --appRoot . --flowId onboarding ``` `weir_release` is the one command that does the whole gate correctly: it resolves the flow from `weir/app.json`, runs the full conform gate, and **only publishes the signed config if conform passes** — then fetches the returned manifest back and verifies its version and files. That is the same gate the dry run above exercised, minus the publish step. What actually gets published is `config.json` (the flow spec itself, RFC-010 §2) plus any `customAssets[]` files it references — never an HTML bundle. The already-installed app picks the new config up on its next launch/foreground update check — no Xcode rebuild, no App Store review. See [Concepts → Delivery model](/docs/concepts/#delivery-model) for what happens between "publish" and "the device renders it." ## 7. Check it landed ``` weir funnel --flowId onboarding weir health --flowId onboarding weir app-status --appRoot . ``` `weir_funnel` is the step-through funnel with drop-off per screen, completion rate, and time-to-paywall. `weir_health` is reliability: crash-free flow-session rate, render time percentiles, native-call error rate, fallback count. `weir_app_status` combines the currently published manifest with both reads for every flow declared in `weir/app.json` — the fastest single command to answer "is the flow I just shipped actually live and healthy." ## What's deliberately not a tool call There is no automated compliance/App Review lint in the current toolkit (an earlier `weir_compliance_lint` was removed) — use manual judgment on paywall copy and permission priming before shipping. There is also no tool that clicks through a flow interactively for you beyond `weir_walk`'s scripted personas; anything past that is a manual trace of every branch and interpolated variable downstream of a branch before you call a flow done. --- # Author your first flow **A Weir screen does not scroll.** Every screen renders in one fixed viewport, the same way a native onboarding screen does — there is no scroll fallback for content that doesn't fit. Content that overflows is clipped, not revealed by scrolling. Size every screen's content (title length, option count, feature list length) to fit the viewport at authoring time; don't rely on a user being able to scroll to see the rest. `weir_conform`'s content-overflow check catches a screen that clips, but the fix is always to shorten or restructure the content, never to add scrolling. A flow spec is one JSON file: a `theme`, a list of typed `variables`, and a list of `screens`. This page is a complete, working example you can copy, then the field-by-field reasoning behind it. The full field list for every node type is public at [`weir-spec/SCHEMA.md`](https://github.com/cynisca/weir-spec/blob/main/SCHEMA.md). After any flow edit, re-embed its native baseline before expecting `weir app-doctor` to pass; see [the edit → verify loop](/docs/troubleshooting/#weir_app_doctor-reports-baseline-config-or-baseline-freshness). The example below passes `@x/spec`'s `validate()` with zero errors and zero warnings. It is a five-question fitness intake: it collects a goal and a bodyweight, branches on the goal, computes a plan label in a loader, personalizes a reveal, and ends on a paywall. ## The complete flow Save this as `weir/flows/onboarding.json`. ```json { "specVersion": 4, "id": "onboarding", "name": "Fit onboarding", "meta": { "app": "example-app", "description": "Worked example for the docs site." }, "theme": { "colors": { "background": "#0f1020", "primary": "#5b6cff", "accent": "#f5c451" }, "motion": { "headline": "fade-up" } }, "variables": [ { "id": "goal", "type": "enum", "enumValues": ["cut", "bulk", "maintain"], "description": "The user's primary training goal." }, { "id": "bodyweight", "type": "number", "description": "Current bodyweight in kilograms." }, { "id": "planLabel", "type": "string", "default": "a plan built around your goal", "description": "Computed by the loader; a short human label for the generated plan." } ], "entry": "welcome", "screens": [ { "id": "welcome", "type": "welcome", "title": "Build the *right* body", "subtitle": "Answer three questions and get a plan tuned to your goal.", "socialProof": { "rating": 4.8, "ratingCount": 1240, "tagline": "Trusted by lifters worldwide" }, "cta": { "label": "Get started" }, "next": "goalSelect" }, { "id": "goalSelect", "type": "singleSelect", "title": "What is your goal right now?", "variable": "goal", "options": [ { "id": "cut", "label": "Cut", "value": "cut", "icon": "leaf", "description": "Lose fat, keep muscle." }, { "id": "bulk", "label": "Bulk", "value": "bulk", "icon": "star", "description": "Add size and strength." }, { "id": "maintain", "label": "Maintain", "value": "maintain", "icon": "shield", "description": "Hold steady and recomposition." } ], "next": { "branches": [ { "when": { "var": "goal", "op": "eq", "value": "maintain" }, "goto": "maintainNote" } ], "default": "weightInput" } }, { "id": "maintainNote", "type": "moment", "kicker": "Good choice", "headline": "Maintenance is a *skill*", "body": "Holding a hard-won physique is its own discipline. We will keep you dialed in.", "cta": { "label": "Continue" }, "next": "weightInput" }, { "id": "weightInput", "type": "numberInput", "title": "What do you weigh today?", "variable": "bodyweight", "min": 30, "max": 300, "unit": "kg", "style": "stepper", "step": 1, "cta": { "label": "Next" }, "next": "personalizing" }, { "id": "personalizing", "type": "loader", "title": "Building your plan", "durationMs": 2500, "messages": ["Reading your goal", "Balancing calories and volume", "Finalizing {{planLabel}}"], "computes": ["planLabel"], "next": "planReady" }, { "id": "planReady", "type": "moment", "kicker": "Ready", "headline": "Your *{{goal}}* plan is ready", "body": "We built {{planLabel}} for a body weight of {{bodyweight}} kg.", "cta": { "label": "See my plan", "variant": "accent" }, "next": "paywall" }, { "id": "paywall", "type": "paywall", "headline": "Start your *{{goal}}* plan", "features": [ { "icon": "check", "title": "Adaptive weekly targets" }, { "icon": "clock", "title": "5-minute check-ins" } ], "products": [ { "id": "app.example.pro.yearly", "label": "Yearly", "priceHint": "$39.99/yr", "badge": "BEST VALUE", "highlighted": true }, { "id": "app.example.pro.monthly", "label": "Monthly", "priceHint": "$5.99/mo" } ], "cta": { "label": "Start now", "variant": "accent" }, "footnote": "Cancel anytime." } ] } ``` ## Field by field **Top level.** `specVersion` must be exactly `4` (the current spec version — RFC-010). `id` and `name` are required. `entry` names the first screen; if you omit it, the first screen in the array is the entry. `meta` is optional labeling. **Variables** are typed and declared once. This flow uses three: | Variable | Type | Written by | Read by | |---|---|---|---| | `goal` | `enum` (`cut`/`bulk`/`maintain`) | the `goalSelect` screen | the branch on `goalSelect`, and `{{goal}}` in later copy | | `bodyweight` | `number` | the `weightInput` screen | `{{bodyweight}}` in `planReady` | | `planLabel` | `string` | computed by the `personalizing` loader | `{{planLabel}}` in the loader and `planReady` | An `enum` variable must declare `enumValues`. A `singleSelect` that writes an `enum` variable must use option values inside that set — here each option's `value` (`cut`/`bulk`/`maintain`) is one of `goal`'s `enumValues`. **Branching.** `goalSelect.next` is a conditional transition, not a static screen id. Its `branches` are checked in order; the first `when` that matches wins, and `default` is taken if none match. Here a `goal` of `maintain` goes to the `maintainNote` moment; every other goal falls through to `weightInput`. Both paths rejoin at `weightInput`, so no screen is stranded. A condition is `{ "var": , "op": , "value": }`. The operator must fit the variable type: `gt`/`gte`/`lt`/`lte` need a `number` variable, `includes` needs a `stringArray`, and `eq`/`neq`/`in`/`nin` work on scalars. `eq` on the `goal` enum is valid. **Interpolation.** Any `{{variableId}}` in a `title`, `subtitle`, `headline`, `body`, `sealedTitle`, or a loader/demo message is replaced at render time. `planReady` interpolates all three variables. One trap: a variable that only a loader `computes` has no value until runtime, so it renders blank in preview — this example gives `planLabel` a `default`, so it shows real text in preview too. Interpolating a computed variable with no default earns a validation warning for exactly this reason. **The `*emphasis*` convention.** Wrapping a run of a `title` or `headline` in asterisks (`Build the *right* body`) renders it in the brand's accent treatment. It is presentation only and does not affect the event stream. ## Verify it Two commands, in order. First check the spec is structurally and semantically valid — this is fast and needs no browser: ``` weir walk --specPath weir/flows/onboarding.json --outDir .eval-out/walk ``` `weir_walk` builds the spec and drives it headlessly. A spec with an unknown screen target, a branch on an undeclared variable, or a type mismatch fails here with an agent-legible `path: message [code]` error before you ever build a bundle. See [Sandbox preview & branch verification](/docs/sandbox-preview/) for reading its screenshots and `walk-report.json`. Then run the full contract gate against a built bundle: ``` weir conform --bundleDir ``` `weir_conform` walks the built bundle with personas and asserts the whole instrumentation contract: the walk reaches `complete()`, every declared variable gets set, the emitted event stream matches the manifest, and the native-feel checks pass. It streams `screen N/total: ok` to stderr as it walks (expected runtime is a few seconds for a ten-screen flow — it is not hung), and exits 0 when every check is green. Inside an onboarded app the usual path is `weir embed --appRoot . --flowId onboarding` (which builds the bundle and prints the exact `--bundleDir` to pass) — see [Quickstart](/docs/quickstart/). `weir_release` runs this exact gate for you internally before they publish, so this is for the embedded (offline) artifact specifically, not a required step before every remote publish. ## Common mistakes - **A branch `goto` (or `next`) naming a screen that does not exist** — reported as `unknown_target`. Every `goto`, `default`, and static `next` must be a real screen `id`. - **A branch condition on an undeclared variable, or a wrong-typed operator** — `gt` on a string variable, `includes` on a non-`stringArray`. Declare the variable and match the operator to its type. - **An unreachable screen** — a screen no transition leads to fails validation. Make sure every screen is a `next`, `goto`, or `default` target of another, or falls through in array order. - **A `singleSelect` on an `enum` variable with an option value outside `enumValues`** — reported as `enum_value_out_of_range`. - **Interpolating `{{name}}` for a variable you never declared** — reported as `interpolation_unknown_variable`. The exhaustive field shapes and the JSON Schema are public at [`weir-spec/SCHEMA.md`](https://github.com/cynisca/weir-spec/blob/main/SCHEMA.md) and the [`weir-spec`](https://github.com/cynisca/weir-spec) repo. The 13 stock screen types (plus `custom`) are summarized on [Reference → Node types](/docs/reference/#node-types). --- # Sandbox preview & branch verification This is the single step an unsupervised agent must not skip: proving the flow renders and every branch resolves *before* shipping it. Two tools do it, both fully local, both needing no account, no server, and no tokens. ## Preview it in a browser: `weir dev` ``` weir dev --specPath weir/flows/onboarding.json --serve ``` `weir_dev` serves the spec in-browser with mocked native behavior for every **stock** screen type. The mock provides fake permission dialogs, a mock localized product catalog (including an intro-offer product and a non-USD tier), a safe-area/notch overlay, and keyboard-inset simulation — so a browser shows what the device shows for stock screens. (A `custom` screen has no browser preview — see [Quickstart](/docs/quickstart/#3-preview-it).) With `--serve` it prints an `http://` URL; without it, a `file://` path. ### Force a native-call outcome with a persona A persona pins the outcome of each native call, so you can look at one specific path: ``` --persona "notifications=deny,purchase=cancel" ``` The persona string is comma-separated `key=value` pairs. The keys are the native-call decisions: | Key | Values | Controls | |---|---|---| | `notifications` | `grant` | `deny` | the system notification-permission prompt result | | `tracking` | `grant` | `deny` | the ATT prompt result (iOS; `unavailable` on Android) | | `health`, `camera`, `location` | `grant` | `deny` | the matching permission prompt result | | `purchase` | `success` | `cancel` | `fail` | how a paywall `purchase.start` resolves | | `variant` | `:` | force this preview onto one arm of an experiment declared in the flow's `experiments[]` | | `userId` | any string | pin the bucketing key an experiment's arm assignment hashes — normally left unset | Any key not in this table (a typo'd permission type, for example) is rejected with the list of valid keys — a persona string is validated, not silently ignored. Any decision you leave out uses the mock's default. A persona changes only native-call outcomes — it does not answer select/input screens for you. ### Force a specific experiment arm A screen with a `variant` gate only renders for users assigned to one of its `showFor` arms (see [Reference → Experiments and the variant gate](/docs/reference/#experiments-and-the-variant-gate)). Both `weir_dev` and `weir_walk` pick the default `userId` ("`eval-user`") unless told otherwise, so an ungated preview/walk always resolves to whichever arm that userId happens to bucket into — usually control. To see a specific arm, force it directly: ``` weir dev --specPath weir/flows/onboarding.json --persona "variant=paywall_copy_v1:treatment" --serve weir walk --specPath weir/flows/onboarding.json --variant paywall_copy_v1:treatment --outDir .eval-out/walk ``` `weir_walk` also accepts `variant` as a top-level field (applied to every persona at once) instead of repeating it inside each persona string. Either form searches for a `userId` that actually buckets into the requested arm and pins it — it does not change the flow spec's gate. An unknown experiment or variant id fails with the full list of ids the flow spec actually declares. ## Walk every branch headlessly: `weir walk` ``` weir walk --specPath weir/flows/onboarding.json --outDir .eval-out/walk ``` `weir_walk` drives the same mocked preview with Playwright for one or more personas, screenshots every screen on each persona's path (including the fake dialogs), and writes one `walk-report.json` per persona. This is how you confirm a *specific* path resolves — not just that the tool exited 0. ### Drive typed answers to reach a conditional path To reach the branch a specific answer triggers, drive the select/input answers, not just the native-call outcomes. Pass answers as repeated `--answerMaps` JSON values, one map per persona: ``` --answerMaps '{"goal":"maintain"}' --answerMaps '{"goal":"cut"}' ``` The first map takes the `maintainNote` branch; the second falls through to `weightInput`. Run both to prove both arms of `goalSelect.next` render. Remember array fields need a repeated flag — two `--answerMaps` flags, not one flag with two values. ## Read the output — do not just check the exit code ### `walk-report.json` fields Each report records what the persona actually did: | Field | Meaning | |---|---| | `persona` / `personaConfig` | the persona's label and its full parsed config (permissions, purchase outcome, answers, forced variant) | | `path` | the ordered list of screen ids this persona visited | | `screens` | one entry per visited screen: index, screen id, type, and the screenshot file path | | `events` | every native-call event the walk made (the raw envelopes, not just `complete`/`dismiss`) | | `appliedAnswers` | the subset of your `answerMaps` that were actually submitted through a real DOM control | | `variables` | the value of every flow variable when the walk ended | | `branchDecisions` | each `next.branches` evaluation and the target it chose | | `reachedComplete` | whether the walk reached `complete()` | Check three things: the `path` includes the screens that answer's branch should reach, the `branchDecisions` show the condition you expect firing, and `variables` has the interpolated variables set to real values (not blank). ### Inspect the screenshots The screenshots land under the `--outDir` you passed, one PNG per screen per persona (`.eval-out/walk/**/*.png`). Open them. A coding agent should read the PNGs directly as images — this is the step that catches a screen that validates but renders wrong (an undefined variable showing blank, a branch that never gets visited, copy overflowing the safe area). The CLI prints a placeholder line for image blocks and the file path; use the path to open the real PNG. ## Troubleshooting the walk - **"My branch never gets visited."** The persona's `--answerMaps` did not drive the answer the branch needs. Check `branchDecisions` in the report: it shows which condition evaluated and why the other target won. Confirm the answer key matches the screen's `variable` id. - **"A variable is undefined downstream."** A screen interpolates `{{x}}`, but no screen on this persona's `path` wrote `x`. Either the writing screen is on a different branch, or `x` is only `computes`d by a loader with no `default` (it renders blank in preview). Give it a `default`, or make sure the persona's path reaches the screen that sets it. - **The walk exits 0 but a screen looks wrong.** Exit 0 means the walk completed, not that the screens are correct. Reading the screenshots is the only check for that. Once every branch renders and `walk-report.json` shows the paths and variables you expect, ship it — [Quickstart](/docs/quickstart/) (`weir_release`, which runs the full `weir_conform` gate before it publishes). --- # Run everything locally The whole loop — author, serve, render, collect events, experiment — runs on one machine with **no Weir account, no hosted box, and no tokens**. This is the front door, not a footnote. The authoring tools (`weir_dev`, `weir_walk`, `weir_conform`, `weir_embed`) are already pure local operations — the npm package alone gives you all of these, right now, with no further setup. ## Local API + dashboard, no repo checkout With only the shipped npm package, run: ```sh weir dashboard ``` This starts a real local `@x/api` in dev mode plus the prebuilt dashboard SPA, signs the dashboard in automatically, and prints both the local API URL and dashboard URL. It needs no repo checkout, pnpm, account, signing key, or token. This is the npm-only integrator's front door to a real local delivery origin: use the printed API URL for `WEIR_API_URL` when publishing locally. It prefers API port `8787`; if that port is occupied, it chooses a free port and tells you which one it used. `weir dashboard` requires Node.js 22+ because its local API uses the built-in `node:sqlite` module, unflagged only from Node 22. Every other Weir command still works on Node 18+. ## Full vendor repo path: start the server in dev mode The full `services/api` vendor repo remains for engineers with private-monorepo access who need production deployment configuration, real signing keys, or to develop the API itself. It is not published as a standalone npm package or image; npm-only integrators should use `weir dashboard`. ``` cd services/api && WEIR_API_DEV_MODE=true pnpm dev ``` That one command gives a fully working local delivery origin on `http://localhost:8787`. In dev mode it makes two guarantees: - **Ephemeral signing key.** The server generates a fresh Ed25519 keypair per process, so you never set `WEIR_API_SIGNING_KEY_B64` for local work. (A real deployment must set it or refuse to boot.) - **Auth is optional.** With `WEIR_API_DEV_MODE=true` and no `WEIR_API_TOKENS` configured, every caller is treated as the default app — no bearer token needed. (Outside dev mode, zero tokens is a boot-time refusal.) Dev mode is fail-closed: it refuses to start if it detects production signing-key markers, so you cannot accidentally run it against real state. ## Point the MCP tools at localhost — they already do The remote-touching tools (`weir_release`, `weir_funnel`, `weir_health`, `weir_events`, `weir_app_status`, `weir_rollback`, `weir_experiment_*`) default `WEIR_API_URL` to `http://localhost:8787`, and omit the auth header when `WEIR_API_TOKEN` is unset. So with the dev server running, you set **no environment variables at all**: ``` weir release --appRoot . --flowId onboarding weir app-status --appRoot . ``` The publish → manifest → OTA loop runs end to end against localhost: `weir_release` builds, conforms, and publishes `config.json` (plus any `custom`-screen assets) to your local server; the server validates and signs it, then updates `/manifest/:flowId`; an app pointed at that origin promotes the config on its next update check; and `weir_funnel`/`weir_health`/`weir_events` read the ingested result back. Zero network egress. If the tools cannot reach the server they print `could not reach the Weir API at http://localhost:8787/...` — start the dev server first. ## Point the dashboard at your local API **The dashboard already defaults local.** Its API base falls back to `http://localhost:8787` unless overridden, so a fresh checkout's `vite dev` points at your local dev server out of the box — a production build sets `VITE_API_BASE` at build time to point at the live API instead. You only need to override anything if you're running a local dev server on a different port, or want a dev build to talk to the live API. Two ways, both skipping Google/Firebase sign-in entirely: 1. **In the running app.** On the sign-in screen, open **"Advanced: sign in with a bearer token"**. Paste any per-app bearer token (any value works against a no-token dev server), and set the **"API base URL override (local dev only)"** field to `http://localhost:8787`. The base is stored in `localStorage` under `weir_dashboard_api_base`. 2. **At build time.** Set `VITE_API_BASE=http://localhost:8787` before `vite dev`/`vite build`. Confirm the dashboard's reads (Overview, Funnel, Health, Events) show the flow you published locally, not empty or production data. ## Render the local bundle on a device — plaintext caveats Both SDKs take a caller-supplied server URL through their public init parameters, so you point them at `http://localhost:8787` (or `http://10.0.2.2:8787` from an Android emulator) with no source changes. But an `http://` (not `https://`) origin trips each platform's cleartext-traffic block: - **iOS — App Transport Security.** The demo app's `Info.plist` has no `NSAppTransportSecurity` key, so default ATS blocks plaintext `http://localhost` and the fetch fails with no clear error. Add an `NSExceptionDomains` entry for `localhost` and `127.0.0.1` (with `NSExceptionAllowsInsecureHTTPLoads`) to the demo `Info.plist` before pointing it at a local server. - **Android — already handled.** The demo's `res/xml/network_security_config.xml` already permits cleartext for `10.0.2.2`, `localhost`, and `127.0.0.1`, wired via the manifest. No change needed. ## The embedded-baseline path needs no server at all `weir_embed` builds the flow into the app's own resources as a baseline bundle, so the app renders a working onboarding flow with zero network and zero prior publish (see [Concepts → Embedded baseline config](/docs/concepts/#embedded-baseline-config)). That is the bottom rung of the resolve order and the strongest form of "runs locally" — it needs neither the dev server nor a device network. Use it to prove the flow renders before you wire up delivery. ## What still needs a real toolchain Real-device verification of the native SDKs — permission/purchase dialogs, app-lifecycle behavior, actual rendering on a simulator or emulator — is Maestro (`sdk-ios/maestro/`, `sdk-android/maestro/`), which needs a prior `xcodebuild` of `WeirDemo.app` (iOS) or a built and installed `:demo` APK on a booted emulator (Android, needs JDK 17). Those suites are not runnable without the mobile toolchain, and each one's own README states its current run status honestly — check it before assuming a flow currently passes. The authoring, serving, ingest, and experiment layers on this page do not need any of that toolchain. --- # Reference Generated in part straight from source at build time (`packages/mcp/src/tools/index.ts`'s `ALL_TOOLS` and `packages/spec/src/events.ts`'s `EVENT_TYPES`) so the tool and event lists here can't silently drift from the code the way prose docs do. Node-type field shapes mirror the spec's schema source of truth — the full Zod-equivalent shapes and JSON Schema are public at [`weir-spec/SCHEMA.md`](https://github.com/cynisca/weir-spec/blob/main/SCHEMA.md) and [`weir-spec/schema/flow.schema.json`](https://github.com/cynisca/weir-spec/blob/main/schema/flow.schema.json). ## MCP tools & CLI verbs Every tool is defined once and mounted on two identical surfaces from the same list: an MCP server (`packages/mcp/src/server.ts`, stdio) and a CLI (`packages/mcp/bin/weir.ts`, one subcommand per tool, generic `--fieldName value` flag parsing against the same Zod schema). The CLI verb is always the tool name with its `weir_` prefix stripped and underscores turned to dashes. | MCP tool | CLI verb | What it does | |---|---|---| | `weir_dev` | `weir dev` | Browser preview of a built bundle (stock screen types only — a custom screen has no browser preview) with deterministic mocked native behavior: | | `weir_walk` | `weir walk` | Persona/branch screenshotter — first-class in the toolkit, replacing weir_screenshot. | | `weir_conform` | `weir conform` | Run the native-feel contract eval against a built browser-preview bundle directory (the internal index.html + manifest.json authoring harness, NOT the shipped deliverable — the published artifact is config.json): | | `weir_embed` | `weir embed` | Embed a flow's native v4 config.json and referenced assets at the baselineBundle declared in weir/app.json (requires a local app rebuild). | | `weir_funnel` | `weir funnel` | Step-through funnel with drop-off per screen for a flow, from the Weir API's ingest DB (GET /read/funnel/:flowId). | | `weir_health` | `weir health` | Reliability metrics for a flow (crash-free flow-session rate, p50/p95 flow_render_ms, bridge_error rate, flow_fallback count, queue flush failure rate), from the Weir API's ingest DB (GET /read/health/:flowId). | | `weir_events` | `weir events` | Live-ish tail of the most recent ingested events, for device-day debugging (GET /read/events). | | `weir_app_doctor` | `weir app-doctor` | Validate a host app's versioned weir/app.json contract: | | `weir_audit` | `weir audit` | Audit a host app's declared Weir onboarding contract without changing it: | | `weir_ship_ready` | `weir ship-ready` | Runtime readiness profile for a BUILT application, complementing weir_app_doctor's source-contract checks. | | `weir_app_status` | `weir app-status` | Summarize configured Weir flows for one app: | | `weir_app_token` | `weir app-token` | Mint a cryptographically random per-app Bearer token and print the exact entry an authorized services/api operator must add to WEIR_API_TOKENS. | | `weir_app_scaffold` | `weir app-scaffold` | Generate weir/app.json and a starter flow spec (+ its embedded native v4 config.json baseline) for a brand-new app id, so a new integration starts from a validated, doctor-passing skeleton instead of a hand-written config. | | `weir_init` | `weir init` | Scaffold a brand-new app's Weir integration in under a minute: | | `weir_rollback` | `weir rollback` | Revert flowId's remote config to a prior signed version via POST /rollback/:flowId — a server-side forward-rollback copy (publish history is append-only, nothing is deleted or overwritten; this creates a new, higher version with the target version's exact bytes). | | `weir_release` | `weir release` | Release one configured app flow safely: | | `weir_experiment_scaffold` | `weir experiment-scaffold` | Generate a valid pre-registration markdown doc skeleton from a flow's experiments[] entry: | | `weir_experiment_start` | `weir experiment-start` | Create an experiment against the Weir API from a pre-registration doc (run weir_experiment_scaffold first to generate a valid one from the flow's experiments[] entry). | | `weir_experiment_status` | `weir experiment-status` | Render an experiment's live state from the Weir API: | | `weir_experiment_decide` | `weir experiment-decide` | Apply the pre-registered decision rule to a running experiment, mechanically, from its pre-registration doc. | | `weir_experiment_ship_winner` | `weir experiment-ship-winner` | Ship the winning arm of a decided experiment in one guarded step: | | `weir_asset_tokens` | `weir asset-tokens` | List every valid moment.image/welcome.image token and every option.icon token, each with a human description of what it actually looks like (written by looking at the asset, not its name) — so you never pick one blind. | | `weir_schema` | `weir schema` | Print the flow-spec vocabulary — top-level flow shape, theme/colour tokens, and every screen type's required/optional fields with a minimal valid example — generated live from the real Zod schema in packages/spec, not a hand-written reference. | | `weir_components_sync` | `weir components-sync` | Sync the app's registered native components (weir/components.json) to the Weir API's component manifest (PUT /components/:appId) and write the generated WeirComponentRegistry.generated.{swift,kt,ts} files (one per platform with at least one declared component) embedding the assigned manifestVersion and the computed registryHash. | **Retired, not in this list:** an earlier surface also had `weir_validate`, `weir_lint`, `weir_render_preview`, `weir_screenshot`, `weir_list_flows`, and `weir_diff_baseline`. They are unregistered — some of their internals live on as library code other tools call, but they are not callable tools or CLI verbs today. If you find a doc anywhere in this repo referencing those by name as if they're live commands, the code (`tools/index.ts`'s `ALL_TOOLS`) is correct and the doc is stale — `weir_dev` + `weir_walk` cover the preview/screenshot ground those retired tools used to. ### Tool detail ### `weir_dev` CLI: `weir dev` Browser preview of a built bundle (stock screen types only — a custom screen has no browser preview) with deterministic mocked native behavior: permission grant/deny personas, purchase success/cancel/fail personas, mock localized product catalog (incl. an intro-offer + a non-USD PPP-tier product), visible fake system dialogs, safe-area/notch overlay, keyboard-inset simulation. Provide exactly one of `specPath`/`specJson`, optional `persona` (e.g. "notifications=deny,purchase=cancel"), and `serve`/`port` to run a local static server instead of a file:// path. ### `weir_walk` CLI: `weir walk` Persona/branch screenshotter — first-class in the toolkit, replacing weir_screenshot. Drives a dev-mocked bundle headlessly, screenshotting every screen along the path each persona produces (fake permission/purchase dialogs included), and writes a walk-report.json (path, events, final variables per persona). Provide exactly one of `specPath`/`specJson`, plus optional `personas` (persona flag strings), `answerMaps` (drive typed answers into select/input screens to reach a specific branch — one JSON object per persona), `variant` (force every persona onto one experiment arm), and `outDir`. ### `weir_conform` CLI: `weir conform` Run the native-feel contract eval against a built browser-preview bundle directory (the internal index.html + manifest.json authoring harness, NOT the shipped deliverable — the published artifact is config.json): persona walk reaches complete(), auto-emitted event stream matches the manifest, every declared variable gets set, purchase/permission calls only go through the mocked native-call path, no literal currency strings, no network on the paint path, and perf budgets (TTI, gz bundle size). Expected runtime: a headless persona walk through a ~10-screen flow takes roughly 3-5 minutes (each screen renders in a real browser + settles animations); this is normal, not a hang. Progress is streamed to stderr as `screen N/total: ok` while the walk runs, so a caller polling output can confirm it's alive rather than assuming it stalled. ### `weir_embed` CLI: `weir embed` Embed a flow's native v4 config.json and referenced assets at the baselineBundle declared in weir/app.json (requires a local app rebuild). Prefer flowId mode: it resolves both source and destination from the app contract. The explicit appResourcesDir mode remains available only for legacy HTML preview bundles. Remote delivery without a rebuild is available via weir_release; see https://weir-docs.web.app for the delivery model. ### `weir_funnel` CLI: `weir funnel` Step-through funnel with drop-off per screen for a flow, from the Weir API's ingest DB (GET /read/funnel/:flowId). Also reports the launch-plan §7 metrics: completion rate, time-to-paywall median, verdict_ready reach rate, trial-start rate. Pass --by variant to split per variant. ### `weir_health` CLI: `weir health` Reliability metrics for a flow (crash-free flow-session rate, p50/p95 flow_render_ms, bridge_error rate, flow_fallback count, queue flush failure rate), from the Weir API's ingest DB (GET /read/health/:flowId). ### `weir_events` CLI: `weir events` Live-ish tail of the most recent ingested events, for device-day debugging (GET /read/events). --tail N controls how many rows (default 50); --flow filters to one flowId. ### `weir_app_doctor` CLI: `weir app-doctor` Validate a host app's versioned weir/app.json contract: each flow spec, embedded native v4 config baseline, optional integration file, delivery-mode declaration, and telemetry/fallback configuration. It never reads secrets or changes the app. ### `weir_audit` CLI: `weir audit` Audit a host app's declared Weir onboarding contract without changing it: inventory flows, stock and app-owned custom components, embedded baselines, remote-update and telemetry declarations, then include the real weir_app_doctor result. It explicitly does not invent active-install coverage, device proof, or business-outcome estimates. ### `weir_ship_ready` CLI: `weir ship-ready` Runtime readiness profile for a BUILT application, complementing weir_app_doctor's source-contract checks. Opens the actual artifact and verifies the SDK reached the binary, each declared baseline bundle is present INSIDE the artifact, and those bundle bytes are identical to the source tree that was conformed. For standard React Native/Expo artifacts, it also reads WeirIngestWriteToken/weir.ingestWriteToken directly; other hosts report ingest and kill-switch configuration from caller assertions — undetermined is a WARN, never a silent pass. Motivating incidents: a stale XcodeGen rewrote an iOS resource folder reference to a repo-root path, so the app built green with NO baseline bundle while app-doctor stayed happy; and a prettier pre-commit hook rewrote a generated manifest.json, making a byte-exact freshness check permanently unsatisfiable. Neither is visible from source alone. ### `weir_app_status` CLI: `weir app-status` Summarize configured Weir flows for one app: currently published manifest bundle plus existing funnel and health reports. Reads WEIR_API_URL and optional WEIR_API_TOKEN from the environment; secrets never live in weir/app.json. ### `weir_app_token` CLI: `weir app-token` Mint a cryptographically random per-app Bearer token and print the exact entry an authorized services/api operator must add to WEIR_API_TOKENS. It only generates a secret locally: it does not contact, register, or modify any server. ### `weir_app_scaffold` CLI: `weir app-scaffold` Generate weir/app.json and a starter flow spec (+ its embedded native v4 config.json baseline) for a brand-new app id, so a new integration starts from a validated, doctor-passing skeleton instead of a hand-written config. Does not generate native Swift wiring — run weir_app_doctor after this and wire Weir.configure/present yourself. ### `weir_init` CLI: `weir init` Scaffold a brand-new app's Weir integration in under a minute: weir/app.json, a starter specVersion-4 flow (welcome + paywall, using only stock screen types) that already passes validate() and the weir_conform gate, and its embedded native v4 config.json baseline. Zero network, zero account required. Prints the agent-legible next steps: wire the native SDK, weir_app_doctor, optional browser QA, then a dry-run release gate. ### `weir_rollback` CLI: `weir rollback` Revert flowId's remote config to a prior signed version via POST /rollback/:flowId — a server-side forward-rollback copy (publish history is append-only, nothing is deleted or overwritten; this creates a new, higher version with the target version's exact bytes). Requires a running Weir API (default http://localhost:8787 for local dev, override WEIR_API_URL to point at your Weir account's API) and WEIR_API_TOKEN if it requires one. Run weir_app_status or GET /read/releases/:flowId first to see which versions are available. ### `weir_release` CLI: `weir release` Release one configured app flow safely: resolve it from weir/app.json, build once, run the full conform gate, and publish those exact bytes only if conform passes. With dryRun=true it performs every local check but never calls the API; a live release fetches the returned manifest and verifies its bundle/version. With draft=true (RFC-007), a build that FAILS conform is published to a draft slot instead — visible only to the dashboard's draft-preview view, never to /manifest or any real user; a build that passes conform is refused (use plain release for that). ### `weir_experiment_scaffold` CLI: `weir experiment-scaffold` Generate a valid pre-registration markdown doc skeleton from a flow's experiments[] entry: every mechanical field weir_experiment_start requires (exact arms JSON, minSamplePerArm/minDurationMs, and the decision rule with the literal ≥/≤/× thresholds the parser needs) is already correct — only the hypothesis and primary-metric prose are left as TODOs for you to fill in. Run weir_experiment_start against the result as-is (with the TODOs replaced) rather than hand-writing the format from scratch. ### `weir_experiment_start` CLI: `weir experiment-start` Create an experiment against the Weir API from a pre-registration doc (run weir_experiment_scaffold first to generate a valid one from the flow's experiments[] entry). Validates the doc has every mechanical field (hypothesis, arms, primary metric, fixed horizon, decision rule), validates the configured flow's spec `experiments[]` entry matches the doc's arms exactly, and refuses if an experiment with this id already exists (pre-registration is immutable — never recreated). ### `weir_experiment_status` CLI: `weir experiment-status` Render an experiment's live state from the Weir API: arms with assigned/converted/conversionRate, SRM flagged/not, assignment-drift count, and day-N-of-horizon. Results are explicitly labeled INTERIM until the fixed horizon is reached — this tool never applies the decision rule (see weir_experiment_decide for that). ### `weir_experiment_decide` CLI: `weir experiment-decide` Apply the pre-registered decision rule to a running experiment, mechanically, from its pre-registration doc. REFUSES before the fixed horizon (reports the exact unlock date, not a result). At/after horizon, applies the doc's ordered invalid/ship-treatment/ship-control/no-call rule and reports the verdict plus the exact follow-up (which arm to release via weir_release) — it does not itself publish; the release step stays a separate, explicit action. ### `weir_experiment_ship_winner` CLI: `weir experiment-ship-winner` Ship the winning arm of a decided experiment in one guarded step: go through weir_experiment_decide's exact horizon/pre-reg guard, transform the flow spec (drop the losing arm's gated screens, un-gate the winner's, remove the experiments[] entry, re-link transitions), run the FULL weir_release conform+publish gate against the transformed spec, and finally POST /experiments/:id/stop. REFUSES before the fixed horizon, on a no-call/invalid verdict, if the transform cannot be resolved statically, or if the transformed spec fails conform — never publishing a best-effort broken flow. dryRun (the default) prints the would-be transform diff and conforms it but makes NO network write; re-run with --dryRun=false to publish and stop for real. ### `weir_asset_tokens` CLI: `weir asset-tokens` List every valid moment.image/welcome.image token and every option.icon token, each with a human description of what it actually looks like (written by looking at the asset, not its name) — so you never pick one blind. Text only; see https://weir-docs.web.app/docs/assets/ for the real rendered images/icons. If none of the 5 built-in image tokens fit, declare your own via the flow spec's customAssets[] and reference it as "custom:" — see https://weir-docs.web.app/docs/reference/#custom-images. option.icon stays a closed set (no custom-icon upload yet). ### `weir_schema` CLI: `weir schema` Print the flow-spec vocabulary — top-level flow shape, theme/colour tokens, and every screen type's required/optional fields with a minimal valid example — generated live from the real Zod schema in packages/spec, not a hand-written reference. Pass screenType to see just one thing: a screen type (e.g. "singleSelect"), "flow", or "theme". ### `weir_components_sync` CLI: `weir components-sync` Sync the app's registered native components (weir/components.json) to the Weir API's component manifest (PUT /components/:appId) and write the generated WeirComponentRegistry.generated.{swift,kt,ts} files (one per platform with at least one declared component) embedding the assigned manifestVersion and the computed registryHash. Requires a running Weir API (default http://localhost:8787, override WEIR_API_URL) and, if it requires one, WEIR_API_TOKEN. ## Node types 13 stock screen types, closed — "no new stock types" is still the rule; an app that needs something the 13 don't cover registers a real native `custom` component instead of Weir growing the schema. Full field-level shapes are public at [`weir-spec/SCHEMA.md`](https://github.com/cynisca/weir-spec/blob/main/SCHEMA.md). | Node type | Purpose | |---|---| | `welcome` | First screen: pitch + optional star-rating social proof strip and a canned chat demo. | | `singleSelect` | Pick exactly one option from a list of icon tiles or a radio group. | | `multiSelect` | Pick 0+ options into a string-array variable, with min/max selection bounds. | | `slider` | Drag a numeric value between min and max into a number variable. | | `numberInput` | Type a numeric value directly into a number variable. | | `textInput` | Free-text input, with optional suggestion chips, email validation, and a skip affordance. | | `loader` | A timed "personalizing your plan" beat that computes one or more derived variables. | | `socialProof` | A dedicated testimonial/rating screen (distinct from welcome's inline strip). | | `permissionPrime` | Prime and then trigger a real system permission prompt (notifications, ATT, HealthKit, camera, location). | | `paywall` | A purchase screen inside the onboarding flow: feature list, product cards, and a CTA that calls purchase.start. Standalone paywall placements can remain in a dedicated paywall product. | | `moment` | A personalized full-bleed affirmation/reveal beat with real bundled photography, no input. | | `holdToCommit` | Press-and-hold to "seal" an intention into a string variable — emits a dedicated commit event. | | `demo` | A non-interactive, canned sourced-answer chat demo (citation chips under app replies). | | `custom` | A native component the app registered with the Weir SDK (weir components sync) — the escape hatch for anything the 13 stock types don't cover. props is opaque JSON at the spec-schema layer; its real shape is validated against the component manifest at publish time. | ### Node detail ### `welcome` First screen: pitch + optional star-rating social proof strip and a canned chat demo. Key fields: `title, subtitle?, image?, socialProof?, demo?, cta`. ### `singleSelect` Pick exactly one option from a list of icon tiles or a radio group. Key fields: `title, variable, options[>=2], required?, autoAdvance?`. ### `multiSelect` Pick 0+ options into a string-array variable, with min/max selection bounds. Key fields: `title, variable (stringArray), options[>=2], minSelections?, maxSelections?`. ### `slider` Drag a numeric value between min and max into a number variable. Key fields: `title, variable (number), min, max, step?, default?, unit?`. ### `numberInput` Type a numeric value directly into a number variable. Key fields: `title, variable (number), min?, max?, placeholder?, unit?`. ### `textInput` Free-text input, with optional suggestion chips, email validation, and a skip affordance. Key fields: `title, variable (string), placeholder?, suggestions?, validation? (email), skipCta?, benefits?`. ### `loader` A timed "personalizing your plan" beat that computes one or more derived variables. Key fields: `title?, durationMs?, messages[>=1], computes? (variable ids)`. ### `socialProof` A dedicated testimonial/rating screen (distinct from welcome's inline strip). Key fields: `title?, rating?, ratingCount?, testimonials[>=1], cta`. ### `permissionPrime` Prime and then trigger a real system permission prompt (notifications, ATT, HealthKit, camera, location). Key fields: `title, permission, primeCta, skipCta?, benefits?, resultVariable?`. ### `paywall` A purchase screen inside the onboarding flow: feature list, product cards, and a CTA that calls purchase.start. Standalone paywall placements can remain in a dedicated paywall product. Key fields: `headline, features?, products[>=1], cta, restoreLabel?, termsUrl?, privacyUrl?`. ### `moment` A personalized full-bleed affirmation/reveal beat with real bundled photography, no input. Key fields: `headline, body?, image? (asset token), citation?, kicker?, cta`. ### `holdToCommit` Press-and-hold to "seal" an intention into a string variable — emits a dedicated commit event. Key fields: `title, variable (string), suggestions?, allowCustom?, holdMs? (300-5000), sealedTitle?, cta`. ### `demo` A non-interactive, canned sourced-answer chat demo (citation chips under app replies). Key fields: `title?, subtitle?, messages[1..6], cta`. ### `custom` A native component the app registered with the Weir SDK (weir components sync) — the escape hatch for anything the 13 stock types don't cover. props is opaque JSON at the spec-schema layer; its real shape is validated against the component manifest at publish time. Key fields: `component ("."), props?, gating?`. Cross-cutting, on every screen: `theme.motion` presets, a closed `IconToken` SVG set for option/ feature icons, `cta.variant` (`primary` | `accent`, the gold treatment for high-intent screens), `onAccent` text color, and a `*emphasis*` two-tone-headline convention (wrap a run in asterisks inside any `title`/`headline` to render it in the brand's gold serif-italic accent). `moment.image`/ `welcome.image` are also a closed token set — see [Asset tokens](/docs/assets/) to look at every image/icon token before picking one, rather than guessing from its id. ## Custom components and gating A `custom` screen (see [Node types](#node-types) above) names a registered native component and its props: ```jsonc { "id": "bodyStats", "type": "custom", "component": "yourapp.bodyStatsQ", "props": { "min": 120 } } ``` `component` must match `^[a-zA-Z][a-zA-Z0-9_]*\.[a-zA-Z][a-zA-Z0-9_]*$` — always `"."` with **exactly one dot**. The namespace is a single identifier (letters, digits, underscore), so a dotted bundle id (`com.greeter.demo`) **cannot** be the namespace — it violates the one-dot rule. Derive a namespace from your `appId` instead (for example `greeter` from `com.greeter.demo`, then `greeter.hero`). `props` is opaque JSON here; register the component and its real prop schema first with `weir components-sync` (see [Concepts → Custom components and the component manifest](/docs/concepts/#custom-components-and-the-component-manifest)) — a config naming an unregistered component, or sending props that don't match its schema, is refused at publish time. Before build QA, run `weir components-sync --check`: a read-only preflight that fails when `weir/components.json`, the last synced manifest cache, or a generated registry source file has drifted, without contacting the API or writing anything. Every screen (stock or `custom`), and the flow as a whole, can also declare `gating`: ```jsonc { "gating": { "minAppVersion": "1.4.0", // optional — semver "platforms": ["ios"], // optional — omit for "every platform" "fallback": "serveOlderConfig", // "skip" | "substitute" | "serveOlderConfig" (default) "substituteScreen": "fallbackHero" // required iff fallback is "substitute"; that screen must itself carry no gating } } ``` A flow-level `minAppVersion`/`platforms` (same two fields, at the top of the spec) is a cheap whole-flow pre-check before any per-screen gate runs. See [Concepts → Gating](/docs/concepts/#gating) for what a build that fails a gate actually gets served. ## Experiments and the variant gate A flow's top-level `experiments` array declares two-or-more-arm experiments the flow's screens can gate on: ```jsonc { "id": "onboarding_copy_v2", // identifier: ^[a-zA-Z][a-zA-Z0-9_]*$ — no hyphens "variants": [ { "id": "control", "weight": 1 }, // weight optional, defaults to 1 { "id": "treatment", "weight": 1 } ], "holdout": 0, // 0-1, fraction excluded from every arm; optional, defaults to 0 "targeting": { // optional — narrows who is eligible at all "platforms": ["ios"], "locales": ["en"], // BCP-47 prefixes "minAppVersion": "1.4.0", "newVsReturning": "new" // "new" | "returning" | "both" (default) } } ``` A user who fails `targeting` is excluded the same way a `holdout` miss is: no arm assignment, no `variant_assigned` event. `variants` needs at least 2 entries. Any screen can then declare an optional `variant` gate, naming the experiment and which of its variants show that screen: ```jsonc { "id": "pricing_treatment", "type": "paywall", "variant": { "experiment": "onboarding_copy_v2", "showFor": ["treatment"] }, "headline": "…" } ``` A screen with no `variant` field is always included, unchanged. A screen with `variant` set is included in the walk only for users assigned to one of `showFor`'s variants — the same published bundle carries every arm; nothing is rebuilt or republished per arm. **Worked example — a two-arm flow**, one shared `welcome` screen, then a control/treatment split on the paywall headline: ```jsonc { "specVersion": 4, "id": "onboarding", "name": "Onboarding", "experiments": [ { "id": "paywall_copy_v1", "variants": [{ "id": "control" }, { "id": "treatment" }], "holdout": 0 } ], "screens": [ { "id": "welcome", "type": "welcome", "title": "Welcome", "cta": { "label": "Continue" }, "next": "pw_control" }, { "id": "pw_control", "type": "paywall", "variant": { "experiment": "paywall_copy_v1", "showFor": ["control"] }, "headline": "Unlock everything", "products": [{ "id": "pro_monthly", "label": "Monthly" }], "cta": { "label": "Continue" } }, { "id": "pw_treatment", "type": "paywall", "variant": { "experiment": "paywall_copy_v1", "showFor": ["treatment"] }, "headline": "Start your 7-day free trial", "products": [{ "id": "pro_monthly", "label": "Monthly" }], "cta": { "label": "Continue" } } ] } ``` Note the experiment `id` (`paywall_copy_v1`) uses underscores, not hyphens — it goes through the same `Identifier` rule as every other id in the spec (`^[a-zA-Z][a-zA-Z0-9_]*$`). Pre-registration doc filenames are conventionally hyphenated (e.g. `exp-001-paywall-copy.md`) — that is a filename convention only, unrelated to the `id` field inside the flow spec or the pre-registration doc's `## Exact arms` JSON block, which both must satisfy `Identifier`. **What "skipped" actually means, stated exactly:** a gated screen a user's assigned arm doesn't match is not shown, and the walk falls through to the *next screen in array order* — the same implicit fallthrough an ungated screen with no `next` field uses. It is not "the next screen that happens to declare a `next` pointing here" and it is not "the flow ends." Worked example — two arms sharing everything except one screen in the middle: ```jsonc { "experiments": [ { "id": "extra_tip_v1", "variants": [{ "id": "control" }, { "id": "treatment" }] } ], "screens": [ { "id": "weightInput", "type": "numberInput", "title": "What do you weigh today?", "variable": "bodyweight" }, { "id": "extraTip", "type": "moment", "variant": { "experiment": "extra_tip_v1", "showFor": ["treatment"] }, "headline": "One more thing before we build your plan", "cta": { "label": "Continue" } }, { "id": "personalizing", "type": "loader", "title": "Building your plan" } ] } ``` `weightInput` and `personalizing` declare no `next` at all — array order is their transition. A `treatment`-arm user sees all three screens in order. A `control`-arm user reaches `weightInput`, finds `extraTip` gated out for their arm, and falls straight through to `personalizing` — the same next-in-array-order rule, just skipping over the one screen that didn't apply to them. Nothing about `weightInput` or `personalizing` needs to know the experiment exists. Run `weir_experiment_scaffold` against a flow with an `experiments[]` entry to generate a pre-registration doc skeleton with every mechanical field already correct — see [Experiments → Pre-registration](/docs/experiments/#pre-registration). Both `moment.image`/`welcome.image` and `option.icon` are validated closed enums — an unrecognized token in either fails `validate()`/`weir_conform` at authoring time, not a silent runtime fallback. See [Asset tokens](/docs/assets/) to see every valid built-in token rendered before picking one — or read on to supply your own. ## Custom images The five built-in `moment.image`/`welcome.image` tokens (see [Asset tokens](/docs/assets/)) are all one app's (Niyat's) art direction. If none fit your flow, declare your own image once at the top of the spec and reference it as `"custom:"`: ```jsonc { "customAssets": [ { "id": "heroPlant", "path": "assets/hero-plant.jpg" } ] } ``` - `id`: an identifier — reference it from a screen as `"custom:"`. - `path`: a file on disk, resolved relative to the flow spec file's own directory (keep your images next to the spec, or in a subfolder beside it). Supported formats: JPEG, PNG, WebP. **Resolution: supply the image at 3x the viewport, not 1x.** A `moment`/`welcome` image renders full-bleed at the device's own CSS viewport size — typically 390×844 for an iPhone screen — but a modern device pixel ratio needs the source image at 3x that box or it looks soft. For a 390×844 viewport that means a source image of at least **1170×2532**. The Previews page's asset-density check enforces this: any image whose intrinsic size is under 3x its rendered CSS box is flagged (`effectiveScale < 3`). Author at 3x up front — don't wait to trip the check. Use it from any `moment`/`welcome` screen exactly like a built-in token: ```jsonc { "id": "welcomePlant", "type": "moment", "headline": "Grow something *real*.", "image": "custom:heroPlant", "cta": { "label": "Continue" } } ``` `weir_embed`/`weir_release`/`weir_dev`/`weir_walk` read the file and embed it as a data URI at build time — the same zero-network bundle shape as a built-in token, rendered through the exact same path (same scrim, same full-bleed sizing). `weir_conform` checks it resolved and embedded correctly, and the bundle's total size still counts against the 350KB budget honestly — an oversized custom image fails `budget-bundle-size` exactly like any other cause of an oversized bundle. Identical images declared under different ids are embedded once and shared, not duplicated. A `customAssets[]` entry no screen references is legal but pointless (a warning, `unused_custom_asset`, same as an unused variable); a screen referencing an undeclared id is a hard error (`unknown_custom_asset`). **Worked example — a plant-care app's own hero photo:** ```jsonc { "specVersion": 4, "id": "onboarding", "name": "Sprout onboarding", "customAssets": [ { "id": "heroPlant", "path": "assets/hero-plant.jpg" } ], "screens": [ { "id": "welcome", "type": "welcome", "title": "Welcome to Sprout", "cta": { "label": "Get started" }, "next": "moment" }, { "id": "moment", "type": "moment", "headline": "Grow something *real*.", "image": "custom:heroPlant", "cta": { "label": "Continue" } } ] } ``` ## Event vocabulary The instrumentation contract: the exact, fixed set of events the renderer emits. This is the product's core guarantee — agents get full creative freedom on presentation, but the event stream underneath is asserted byte-exact by the `weir_conform` eval. Every event carries a monotonic `seq` starting at 0; `weir_conform` fails if a built bundle's actual emitted stream diverges from what the flow spec implies. | Event type | Fires when | |---|---| | `flow_started` | A flow began rendering, at the declared entry screen. | | `screen_impression` | A screen was shown (its type and position in the flow). | | `quiz_answer` | A `singleSelect`/`multiSelect`/`slider`/`numberInput` screen's answer was recorded. | | `input_submitted` | A `textInput` screen's value was submitted. | | `branch_decision` | A `next.branches` condition was evaluated and a target chosen. | | `variant_assigned` | An experiment variant was assigned (Pillar 3, frozen). | | `loader_completed` | A `loader` screen finished and reports which variables it computed. | | `permission_prompt_shown` | A `permissionPrime` screen's real system prompt was triggered. | | `permission_result` | The system permission prompt resolved granted/denied. | | `paywall_shown` | A `paywall` screen was shown, with the product ids offered. | | `purchase_intent` | The user tapped a paywall CTA for a specific product, before the purchase call resolves. | | `purchase_result` | `purchase.start`/`purchase.restore` resolved: purchased, cancelled, failed, or restored. | | `commit` | A `holdToCommit` screen's press-and-hold completed — a higher-intent signal than input_submitted. | | `screen_skipped` | A skippable screen (`textInput.skipCta`, etc.) was dismissed via its skip affordance. | | `screen_exit` | | | `screen_fallback` | | | `flow_completed` | The flow reached `complete()` — reason is reached_end, purchased, or dismissed. | Every event envelope is `{ flowId, screenId?, variantId?, sessionId, userId?, ts, seq, payload, screenDwellMs? }` (see [Concepts → Instrumentation](/docs/concepts/#instrumentation-contract)). It gets appended to a file-backed offline queue in `seq` order on the native side and flushes to the Weir API's ingest as one batch, carrying device context (platform, app version, SDK version, locale, device class) once per batch rather than once per event. `weir_funnel`/`weir_health`/ `weir_events` all read from that same ingested stream. ## Compliance linting There is currently **no automated compliance/App-Review lint** in the toolkit — an earlier `weir_compliance_lint` tool was removed along with its `COMPLIANCE.md` reference doc. If you're about to ship a flow with a `paywall` or `permissionPrime` screen, that judgment call (clear pricing, no dark patterns, ATT copy matching what the system prompt will say) is manual today. Don't rely on a docs page or a stale skill reference implying otherwise. --- # Asset tokens — see before you ship `moment.image`, `welcome.image`, and `option.icon` (see [Reference → Node types](/docs/reference/#node-types)) each accept one of a small, closed set of tokens. Run `weir asset-tokens` for the same descriptions as text, without leaving your agent host — useful for picking a token without a browser round-trip, though it can't show you the actual pixels. Guessing from the token's name alone is not reliable: a 2026-07-26 cold-start evaluation had a developer building a plant-care app pick `path-anchor` blind, expecting something plant-shaped. It rendered a mosque at sunset. This page (and `weir asset-tokens`) exist so that never happens again. ## The honest constraint — and the fix **The five built-in `moment`/`welcome` image tokens below are real photography, authored for one specific app's (Niyat's) art direction — an Islamic-lifestyle app.** They are not generic, not neutral, and not matched to any other app's brand. If your flow's subject matter doesn't fit olive groves, mosque architecture, and sunset domes, none of these five tokens are a good fit. **You can now supply your own image.** Declare it once at the top of your flow spec and reference it as `"custom:"` from any `moment`/`welcome` screen: ```jsonc { "customAssets": [ { "id": "heroPlant", "path": "assets/hero-plant.jpg" } ], "screens": [ { "id": "moment", "type": "moment", "headline": "Grow something *real*.", "image": "custom:heroPlant", "cta": { "label": "Continue" } } ] } ``` **Supply it at 3x the viewport (about 1170×2532 for the standard 390×844 iPhone viewport), not 1x** — the image renders full-bleed at CSS pixel size, and anything under 3x its rendered box looks soft on a real device pixel ratio. The Previews page's asset-density check flags this; don't wait to trip it. See [Reference → Custom images](/docs/reference/#custom-images) for the full worked example, path resolution rules, and supported formats. It is validated, embedded, and rendered through the exact same path as a built-in token (the same scrim, the same full-bleed sizing, the same 350KB bundle budget), so a plant-care app can finally show a plant instead of picking blind from five photos of somewhere else's brand. The 20 `option.icon` tokens below are still a closed set (no custom-icon upload yet) — mostly generic (book, star, heart, clock, and so on) and travel better across app subject matter, except four with an explicit Islamic-lifestyle framing (`prayer`, `quran`, `dhikr`, `sadaqah`) — pick from the other 16 if that framing doesn't fit your app. ## `moment.image` / `welcome.image` tokens
[image: Close-up olive-tree branches framing a bright, hazy golden sky; an olive grove blurred in the background. Soft, warm, pastoral.]
`verse-hero-olive` Close-up olive-tree branches framing a bright, hazy golden sky; an olive grove blurred in the background. Soft, warm, pastoral.
[image: A sunlit mosque/palace hallway seen through an ornate carved archway with pierced lattice windows, gold light, green-and-gold tiled columns, a hanging lantern, and a potted plant.]
`verse-hero-arch` A sunlit mosque/palace hallway seen through an ornate carved archway with pierced lattice windows, gold light, green-and-gold tiled columns, a hanging lantern, and a potted plant.
[image: A receding corridor of ornate horseshoe arches (Moroccan/Andalusian-style architecture) in warm golden light, tiled dado, repeating perspective down a colonnade.]
`path-reveal-arches` A receding corridor of ornate horseshoe arches (Moroccan/Andalusian-style architecture) in warm golden light, tiled dado, repeating perspective down a colonnade.
[image: A mosque dome and minaret silhouetted against a dusk sky (deep blue fading to orange), dark tree silhouettes in the foreground.]
`path-anchor` A mosque dome and minaret silhouetted against a dusk sky (deep blue fading to orange), dark tree silhouettes in the foreground.
[image: An elevated view of a large white mosque complex at golden hour: a dome and minaret in the foreground, more minarets and palm trees in the distance, blue sky with clouds.]
`welcome-hero` An elevated view of a large white mosque complex at golden hour: a dome and minaret in the foreground, more minarets and palm trees in the distance, blue sky with clouds.
## `option.icon` tokens Single-color line icons, rendered here on a dark tile at the size they actually appear in the app. `prayer`, `quran`, `dhikr`, and `sadaqah` carry the same Islamic-lifestyle framing as the image tokens above; the other 16 are generic.
[image: An open book, two facing pages with a spine down the middle.]
`book` — An open book, two facing pages with a spine down the middle.
[image: A solid crescent moon.]
`moon` — A solid crescent moon.
[image: A solid five-point star.]
`star` — A solid five-point star.
[image: A sun: a circle with eight rays.]
`sun` — A sun: a circle with eight rays.
[image: A single four-point sparkle/diamond shape.]
`sparkle` — A single four-point sparkle/diamond shape.
[image: A solid heart.]
`heart` — A solid heart.
[image: A solid leaf shape with a faint center vein.]
`leaf` — A solid leaf shape with a faint center vein.
[image: A single outlined cloud.]
`cloud` — A single outlined cloud.
[image: A question mark.]
`question` — A question mark.
[image: A compass: an outlined circle with a solid pointer needle.]
`compass` — A compass: an outlined circle with a solid pointer needle.
[image: A notification bell.]
`bell` — A notification bell.
[image: A padlock: a rounded body with a shackle on top.]
`lock` — A padlock: a rounded body with a shackle on top.
[image: A shield outline.]
`shield` — A shield outline.
[image: Two raised, cupped-hand shapes side by side, evoking hands raised in supplication.]
`prayer` — Two raised, cupped-hand shapes side by side, evoking hands raised in supplication.
[image: A closed book with a ribbon bookmark tab at the top.]
`quran` — A closed book with a ribbon bookmark tab at the top.
[image: Six solid dots arranged in a ring, evoking prayer beads (a tasbih).]
`dhikr` — Six solid dots arranged in a ring, evoking prayer beads (a tasbih).
[image: A simple person silhouette (head and shoulders), evoking giving to another person.]
`sadaqah` — A simple person silhouette (head and shoulders), evoking giving to another person.
[image: A clock face with hour and minute hands.]
`clock` — A clock face with hour and minute hands.
[image: A calendar page with two hanging binder rings at the top.]
`calendar` — A calendar page with two hanging binder rings at the top.
[image: An envelope.]
`mail` — An envelope.
[image: A checkmark inside a circle.]
`check` — A checkmark inside a circle.
## Unknown tokens Both `option.icon` and `moment.image`/`welcome.image` are closed enums — an invented or misspelled token in either fails `validate()`/`weir_conform` at authoring time, listing the valid tokens in the error, rather than publishing and rendering wrong (or blank) at runtime. This page still exists because "does not error" was never the whole bar: an author picking a valid token blind, with no idea what it depicts, is exactly how a plant-care app got a mosque at sunset. Validation catches typos; it does not tell you what a token looks like. ## See also - [Reference → Node types](/docs/reference/#node-types) — where `image`/`icon` fields are declared. - [Reference → Custom images](/docs/reference/#custom-images) — supply your own image instead of picking from the five above. - [Reference → Experiments and the variant gate](/docs/reference/#experiments-and-the-variant-gate) — the other schema area with a public worked example. --- # Concepts ## The native component model A flow spec's `screens[]` array is 13 stock types (rendered by the target SDK's native UI components — SwiftUI for an iOS host or React Native components for an RN/Expo host — including `welcome`, `singleSelect`, `paywall`, and so on; see [Reference → Node types](/docs/reference/#node-types)) plus one escape hatch, `custom`, for anything those 13 don't cover. A `custom` screen names a native component the app itself registered: ```jsonc { "id": "bodyStats", "type": "custom", "component": "yourapp.bodyStatsQ", "props": { "min": 120 } } ``` `component` is always `"."` with **exactly one dot**. The namespace is a single identifier (letters, digits, underscore) — so when your `appId` is a real dotted bundle id (`com.greeter.demo`), that `appId` **cannot** be the namespace (it violates the one-dot rule). Derive a namespace from it instead (`greeter` from `com.greeter.demo`, then use `greeter.bodyStatsQ`). `props` is opaque JSON at this layer: its real shape is declared once, on the native side, and checked against what you send at publish time (next section). There is no way to author raw markup or a WebView slot in a v4 flow — a screen the stock vocabulary can't express becomes a real native component, not markup. ## Custom components and the component manifest Registering a component is two things, done together by one command: ```sh weir components-sync --appRoot . ``` 1. It reads `weir/components.json` (one entry per registered component: name, target platform(s), a JSON Schema for its props, the app version it first shipped in) and sends it to the Weir API, which assigns the next **manifest version** for your app — an integer that only ever goes up, the same monotonic-version discipline flow publishing uses. ```json { "version": 1, "components": [{ "component": "fitness.hero", "platforms": ["ios"], "minAppVersion": "1.0.0", "propsSchema": { "type": "object", "properties": { "headline": { "type": "string" } }, "required": ["headline"] } }] } ``` This is the hand-authored shape. Sync expands each `platforms` value into separate per-platform entries in the server-side manifest. The declaration contract is exact: top-level `version` is `1`; every component entry has `component`, `platforms`, `minAppVersion`, and `propsSchema`. Do not add a `description` key — that is not a declaration field. For an Expo host, `platforms` must contain `"reactNative"` (not `"ios"`): ```json { "version": 1, "components": [{ "component": "cardclub.welcome", "platforms": ["reactNative"], "minAppVersion": "1.0.0", "propsSchema": { "type": "object", "properties": { "headline": { "type": "string" } }, "required": ["headline"], "additionalProperties": false } }] } ``` 2. It writes a generated registry source file for the target runtime (`WeirComponentRegistry.generated.swift` or `WeirComponentRegistry.generated.ts`) embedding that manifest version and a **registry hash** — a hash of every registered component's name and props schema. Commit the generated file and install it into the SDK registry before fetching. At publish time, every `custom` screen's `component` is checked against your app's latest synced manifest: the component must be registered for every platform the screen targets, and its `props` must match that component's declared schema. A config naming an unregistered component, or sending props that don't match, is refused before it ever reaches `/manifest`. **Scope the flow to the same runtime.** A custom screen with no screen or flow platform scope is treated as targeting every platform. Therefore, an Expo flow whose declarations say `"platforms": ["reactNative"]` must also retain this top-level field in the flow itself: ```json { "platforms": ["reactNative"], "screens": [/* registered custom screens */] } ``` Otherwise the conform gate correctly asks for iOS and Android registrations too, even though this app only delivers React Native. Keep the flow and component-manifest platforms aligned; do not work around the error by falsely adding another runtime to `components.json`. **The drift check.** Registering a component in Swift code and forgetting to re-sync is the one mistake this exists to catch: the SDK compares its live registrations against the generated file once, at first render. A mismatch emits a `health_registry_drift` event, and in a debug build, stops with an assertion — re-run `weir components-sync` and rebuild. Before build QA, run `weir components-sync --appRoot . --check`. This is a read-only preflight: it makes no API request and writes nothing, but fails when `weir/components.json`, the last synced manifest cache, or a generated registry source file has drifted. If it fails, run the normal sync, review and commit its output, then rebuild. **What your component receives at runtime (RFC-012 §6).** A registered custom component gets its declared props (already validated against your `propsSchema`) plus one namespaced `weir` prop — the seam it uses to drive the flow, since a `custom` screen has no `cta`/`next` of its own: ```ts interface WeirCustomScreenApi { // Resolve this screen's next (or jump to an explicit screen id) and advance — // the custom-screen equivalent of a stock component's CTA press. submit(explicitTarget?: string): void; // Record screen_skipped and advance via next — for a "maybe later" affordance. skip(): void; // Set a flow variable, e.g. a quiz-like screen's answer. setVariable(id: string, value: JSONValue): void; // Record a custom interaction event in the standard event envelope. recordInteraction(type: string, fields?: Record): void; } ``` Your component renders its own CTA and calls `weir.submit()` when done. `submit("someScreenId")` jumps (a Skip-to-last-screen affordance, for example). There is no flow-abort call from inside a custom screen — dismissal semantics belong to the host integration, not to individual screens. **Author, verify, and recover.** Edit `weir/components.json`, run `weir components-sync --appRoot .` against the intended API, and commit/reference the generated registry file in each platform build. The command reports the assigned manifest version and each platform's registry hash. Rebuild and run the app: a debug registry-drift assertion or `health_registry_drift` means the native registrations and generated registry differ. Correct the declarations or native registrations, sync again, rebuild, and retry. To roll back an incompatible declaration, restore the last compatible `components.json`, sync it, and ship an app whose registrations match that generated registry. ## Gating Every screen, and the flow as a whole, can declare a build-compatibility floor: ```jsonc { "gating": { "minAppVersion": "1.4.0", "platforms": ["ios"], "fallback": "serveOlderConfig" } } ``` `fallback` says what a build that fails this screen's gate gets instead: `"skip"` drops the screen; `"substitute"` renders a named sibling screen (which must itself carry no gating — no chains); `"serveOlderConfig"` (the default) disqualifies the whole config version for that build, and the server walks back to the newest version the build actually can render. A build's own SDK version and registered-component registry hash travel with every config fetch, so an old build requesting a config with a component it never registered gets a servable older version instead of a config it cannot render. If no published version is servable, the SDK resolves through its verified cached and app-embedded fallback tiers instead of attempting to draw the rejected config. ## Delivery model > Edit a compatible flow → `weir release` → relaunch the already-installed app → the verified > config can render natively and its event can land in `weir funnel`. That is the v4 loop running in the controlled private alpha. “Compatible” matters: copy, props, ordering, theme, and branches can change within the component vocabulary already in the installed build; adding a new native component still requires an app release. Its operational evidence is qualified: public health identifies the deployed commit and release, while the current dogfood evidence is not a public-beta or conversion result. The loop is built from a few pieces that each do one job: 1. **Publish** — `weir_release` sends `config.json` (the flow spec itself, serialized) plus any `customAssets[]` files it references to the delivery origin (`services/api`, `POST /publish`), which validates it (schema, then component-manifest checks for every `custom` screen) and updates that flow's `/manifest/:flowId` route. 2. **Sign** — the served manifest (config id, spec version, published version, the component manifest version it was validated against, and a sha256 per served file) is signed with an Ed25519 keypair. The private half lives only in deployment secrets (`WEIR_API_SIGNING_KEY_B64`); the paired public key is baked into the SDK build and is safe to distribute — e.g. via Firebase Remote Config alongside the manifest URL. 3. **Fetch, verify, gate, stage** — the installed app checks for an update on launch and on foreground, sends its own app version, SDK version, platform, and registry fingerprint with the fetch, gets back whichever config version its own build can actually render (see Gating above), verifies the Ed25519 signature and every file's sha256, and **stages** the result — never swaps a live render mid-flow. 4. **Promote** — the staged config is promoted to active only at the next flow presentation, never mid-flow. The render path resolves a verified promoted remote config first, then the app-supplied or app-embedded v4 baseline config. Source/version telemetry reports which tier actually rendered. Every served file carries a lowercase SHA-256 of its exact bytes, covered by the same signature — the SDK re-hashes each fetched file before staging and rejects a mismatch. ### Embedded baseline config Every supported v4 host packages a real specVersion-4 config and its declared assets in the app binary. It is native-renderer input, not HTML and not a WebView artifact. SwiftUI accepts a config root containing either a single-flow `config.json` or `flows/.json`; the RN/Expo config plugin embeds a directory containing `config.json`. Custom screens work in this tier because the native/RN component implementation ships in the same app build. `weir embed`/`weir init` now produce a real specVersion-4 `config.json` — the native baseline that ships in the app and that `weir_app_doctor` checks. They also produce the internal browser-preview bundle (`index.html` plus `manifest.json`) used by `weir walk`/`weir conform` for headless QA; that bundle is an authoring harness, not the shipped deliverable. The native baseline (`config.json`) is what the SDK reads at runtime for its embedded fallback. ## Kill switch The revert path for a bad remote flow **must not depend on Weir's own infrastructure being healthy** — so the kill switch lives on Firebase Remote Config, permanently, by design, not on anything Weir serves. A single flag (`weir_onboarding_enabled`, default `false`) plus `weir_manifest_url` / `weir_manifest_public_key` / `weir_ingest_url` / `weir_ingest_token` route the app's delivery and telemetry entirely through remote config the app already trusts. Flipping that flag off returns the app to its native onboarding (`nativeFallback` in `weir/app.json`) without needing the delivery origin to be reachable at all. The app-embedded v4 baseline config is the offline config tier if the host chooses to present Weir while remote delivery is unavailable; the app's separate native onboarding remains the outer fallback controlled by the kill switch. ## Experiments The Weir API's experiment layer (sticky assignment, sample-ratio-mismatch guardrails, peeking guardrails, a results API) is built and tested against a real Weir API, `variant_assigned` is a first-class event in the instrumentation contract above, and the variant→config serving seam means a published config genuinely carries per-arm content. The agent-facing lifecycle — `weir_experiment_start`/`_status`/`_decide`/`_ship_winner` — is registered (see [Experiments](/docs/experiments/) for the full walkthrough). Two experiments are running on this pipeline now: `exp_cob_commitment_first` (CutOrBulk) and `exp_psd_scan_demo_first` (PSD), with 14-day horizons ending around 2026-08-17; decide a winner only via the pre-registered rule at horizon. EXP-001, the first experiment run on this pipeline, is closed — it validated the plumbing end to end but never reached enough real-user traffic to power a decision (see [Experiments](/docs/experiments/) for its closure note). No conversion-lift claim is implied by an experiment being running. `weir_experiment_results` (a narrower, older results-only tool) stays unregistered in `ALL_TOOLS`, superseded by `weir_experiment_status`. ## Instrumentation contract The product's core guarantee, stated plainly: **agents get full creative freedom on presentation, but the event stream underneath a flow is fixed and asserted, not something an agent can silently drop or rename.** Concretely: - The event vocabulary is closed — see [Reference → Event vocabulary](/docs/reference/#event-vocabulary) for the full list, and the public [`weir-spec/EVENTS.md`](https://github.com/cynisca/weir-spec/blob/main/EVENTS.md) for the full spec. `validateEventStream` checks every emitted event parses against its exact shape and that `seq` is monotonic starting at 0. - `weir_conform` asserts the browser-preview artifact's emitted stream, for a scripted persona walk, matches what the flow spec's screens/branches/variables imply. That is authoring-time contract evidence; it does not substitute for native SDK/device tests. - Every event envelope carries `{ flowId, screenId?, variantId?, sessionId, userId?, ts, seq, payload, screenDwellMs? }`. Device context (platform, app version, SDK version, locale, device class) rides once per flush batch, not once per event. - Native appends events to a file-backed offline queue in order and owns flush policy. `weir_funnel`/`weir_health`/`weir_events` read the ingested result of that queue via `services/api`'s `/read/*` routes. - `getResults()` (the experiment results path) dedupes conversions by `user_id`, falling back to `session_id` only for older clients with no host-injected user context — so `assigned` and `converted` are counted per user end to end, not per session. This is why the toolkit doesn't need a visual pixel-diff or a compliance linter to make the "agents get creative freedom" pitch safe: the freedom is scoped to presentation, and the contract that isn't up for grabs is enforced mechanically on every build. --- # 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 | Host | v4 render path | Baseline shape | Status | |---|---|---|---| | Native iOS | `WeirFlowView` or `Weir.present` renders SwiftUI | A resource directory with `config.json` or `flows/.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 In Xcode, add this exact package dependency and select the umbrella product `Weir` for the app target: ```swift .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 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: ```json { "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 These commands operate on the v4 spec and the browser-based authoring harness: ```sh 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 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 ` 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) 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 ### 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: ```swift 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: ```swift 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 > **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 `` 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 requireIngestToken: true and provide WEIR_DEMO_INGEST_TOKEN 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 weir ship-ready against the built artifact: it checks the generated WeirIngestWriteToken (iOS) or weir.ingestWriteToken (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: ```js // 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: ```tsx // 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 ( ); } ``` ### Make the live path actually live After publishing, the app's normal onboarding branch must render ``. 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: ```jsx export default function Welcome({ headline, subhead, ctaLabel, weir, onNext = () => {} }) { const next = () => weir ? weir.submit() : onNext(); return {ctaLabel}; } ``` 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: ```jsx export default function Welcome({ headline, subhead, ctaLabel, weir }) { return ( {headline} {subhead} weir.submit()}>{ctaLabel} ); } ``` 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 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: | 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: ```sh 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 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 ` 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 | 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 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. --- # Monitoring The operating question is simple: **did the version we publish reach people safely?** Start with `weir app-status --appRoot .`. It joins the current manifest with funnel and health reads for every configured flow — a read-only summary and the fastest single check. ## What the dashboard can change The dashboard is no longer inspection-only. Publish/rollback and the experiment lifecycle moved into it (`PLAN.md` M6): the flow workspace exposes **publish** and **rollback** for a released config, and the Experiments view exposes **Start, Stop, and Decide** actions (the live experiment view shows real **Stop** and **Decide** buttons). Those buttons invoke the same gated paths the agent surface uses — `weir_release`'s build → conform → publish sequence, and the experiment lifecycle's pre-registration → fixed-horizon → decide sequence — so a dashboard action and an agent action go through one identical, auditable path, not two un-gated ones. What stays out of the dashboard is **flow authoring**: composing or editing screen JSON, branches, and variables remains repository-owned and agent-first (there is no visual builder). The dashboard operates the lifecycle of a flow you already authored through the agent surface; it does not let a person draw a new screen. If a remote flow is unhealthy, the kill switch (`weir_onboarding_enabled` in Firebase Remote Config) remains the revert path that does not depend on Weir's own infrastructure being reachable. ## Control room The dashboard (`packages/dashboard`) is organized as a **Home** landing page plus a four-item nav — **Home**, **Experiments**, **Apps**, and the current app's **Flows** workspace. Funnel, health, events, and release views live inside the flow workspace (including its publish/rollback actions); experiment detail (arms, traffic, results, SRM, Start/Stop/Decide) lives inside Experiments. Every view renders the same read tools listed below or the same write actions described above — there is no dashboard-only data path. The deployed dashboard at the public URL may lag this information architecture until the pending Firebase redeploy lands; the source in `packages/dashboard` is the source of truth. ## The read tools underneath - `weir app-status --appRoot .` — joins the currently published manifest with funnel and health reads for every flow declared in `weir/app.json`. The fastest single command to answer "is the flow I just shipped actually live and healthy." - `weir funnel --flowId ` — step-through funnel: starts, per-screen drop-off, completion rate, time-to-paywall. - `weir health --flowId ` — reliability: crash-free flow-session rate, render time percentiles, native-call error rate, fallback count (how often the app fell back to `nativeFallback` or the embedded baseline instead of the intended config). - `weir events --flowId ` — a recent raw event trace, for when the aggregated funnel/health views aren't enough to explain a specific drop. ## Three checks after a release 1. `weir app-status --appRoot .` — confirm the expected bundle id/spec version is published. 2. `weir funnel --flowId onboarding` — check starts, screen progression, and completion. 3. `weir health --flowId onboarding` — check fallback count, render latency, native-call errors, and queue flushes. If a remote flow is unhealthy, turn off `weir_onboarding_enabled` in Remote Config. That returns to native onboarding without depending on Weir's delivery origin being reachable at all — see [Concepts → Kill switch](/docs/concepts/#kill-switch). --- # Experiments Experiments are a controlled loop, in exactly this order, and each step gates the next: 1. **Pre-register.** Write the decision contract — question, arms, metric, guardrails, fixed horizon, decision rule — as a numbered markdown doc under `docs/experiments/`, before any API experiment, any variant flow content, or any traffic exists. See "Pre-registration format" below. 2. **Publish.** Author the treatment arm's flow content per the pre-registration (the variant gate on a screen — see below), and create the experiment against the API with `weir_experiment_start` (see "Current build status" for what's real versus still pending). 3. **Traffic.** Real assigned users flow through both arms; `variant_assigned` events land per user; guardrails (SRM, completion parity, reliability) are monitored continuously, not decided on. 4. **Decide.** After the API reports the fixed horizon reached (`ready: true`), read results exactly once and apply the pre-registered rule mechanically. No peeking before that, no re-reading after a decision is made. The decision rule belongs in the pre-registration **before any result exists** — writing the rule after seeing data is exactly the failure mode fixed-horizon pre-registration exists to prevent. ## Current build status — read this before assuming any of this is live Being honest about what's shipped versus designed matters more here than anywhere else in the toolkit, so: - **Sticky assignment, SRM guardrails, peeking guardrails, and the results API** are built and tested against a real Weir API. `variant_assigned` is a first-class event in the instrumentation contract (see [Reference → Event vocabulary](/docs/reference/#event-vocabulary)). - **The variant→config serving seam.** A screen can declare an optional `variant` gate (see [Reference → Experiments and the variant gate](/docs/reference/#experiments-and-the-variant-gate)); the headless engine and the in-app runtime both bucket a user with the same hash function, walk only the assigned arm's screen graph, and emit `variant_assigned`. One published config honestly carries every arm instead of two flow specs silently overwriting each other at `/manifest`. Assignment is recorded and server-verified from the ingested `variant_assigned` event, so it needs no SDK round trip to `POST /assign`. - **The `weir_experiment` MCP verbs (`weir_experiment_start`/`_status`/`_decide`).** The agent-facing lifecycle commands are registered tools — check [Reference → MCP tools](/docs/reference/#mcp-tools) for the live `ALL_TOOLS` list, the source of truth. `start` creates the experiment against the Weir API from a pre-registration doc, after validating the doc's mechanical fields and that the flow spec's `experiments[]` entry matches the doc's arms exactly; it refuses if the experiment already exists (pre-registration is immutable). `status` renders arms/SRM/drift/day-of-horizon and labels results INTERIM before the fixed horizon. `decide` refuses before the horizon (reporting the exact unlock date) and, once reached, applies the doc's decision rule mechanically — it reports the verdict and the exact `weir_release` follow-up but never publishes itself. `weir_experiment_results` still exists in code but stays deliberately **unregistered**; it predates the lifecycle verbs and is superseded by `weir_experiment_status`. - **Current operating state: two experiments are RUNNING now.** `exp_cob_commitment_first` (CutOrBulk, flow `cob_intake`) and `exp_psd_scan_demo_first` (PSD, flow `onboarding`) are live in production with 14-day horizons ending around 2026-08-17. Per-arm funnel reads stay 425-gated before horizon (the peeking guard); decide a winner only via `weir experiment-decide`, only at horizon, only per each experiment's pre-registration doc. This states that the experiments are running and enrolled — it is not a conversion-lift claim, and no winner has been read yet. - **EXP-001 is closed, unpowered (history).** It ran on the pre-pivot webview delivery path, and a production client bug on that path (a stale embedded bundle sending every event with no user id) starved it of traffic — assignments froze at 5 against a 50-per-arm minimum. SRM itself was clean (the split wasn't broken, there just wasn't enough traffic), and the experiment proved the delivery/assignment/ingest/fixed-horizon-gate pipeline works end to end on real infrastructure. No product decision was taken from it. The v4 native pivot replaced the entire delivery path that caused the starvation; the two running experiments above are the properly powered follow-ons. ## Worked example: EXP-001 (CutOrBulk intake) `docs/experiments/exp-001-cob-intake.md` is the first pre-registration and the worked example for the format below. It removed the `credibility` interstitial from `cob_intake` and measured per-user paid-subscription conversion. **It is closed, not running** — see "Current build status" above for why, and the closure note for the full record. Read it as a worked example of the pre-registration format, not as a live or decision-ready experiment. ## Pre-registration format Every pre-registration doc under `docs/experiments/` follows the same sections, in the same order, because the format is what makes "the rule was written before the data" checkable by inspection. Quoting the shape from `exp-001-cob-intake.md` directly (do not invent a different shape per experiment): 1. **Title + status line** — begin with `Status: pre-registered — not created on an API, not published, and no results exist ().` When the experiment is created, update only the operational status (or append an operational addendum); never edit the hypothesis, arms, metrics, horizon, thresholds, or decision rule below it (see "Immutable record"). 2. **Question and hypothesis** — the exact question being asked, and a one-paragraph hypothesis for the direction of effect. EXP-001: "Does removing the full-screen `credibility` interstitial ... increase the rate at which an assigned user starts a paid subscription." 3. **Exact arms** — a table: arm name, id, allocation weight, and the *exact* change, plus the literal JSON experiment declaration (`id`, `variants[].id`/`weight`, `holdout`) that will be passed to the API. EXP-001 pins allocation at 50/50, no holdout, and states plainly: "All other spec fields, app routing, Superwall placement, products, analytics, and native post-intake behavior must be identical." 4. **Metric contract** — a **primary metric** defined precisely enough to compute independently from raw events (EXP-001: per-user `purchase_result: purchased` for the pinned `flowId`, deduped by `userId`, matching `GET /experiments/:id/results` exactly), plus **guardrails**: assignment integrity (SRM not flagged), completion parity (a bounded percentage-point floor per arm, computed independently from `/events`, not the results endpoint), and reliability (fallback/native-call-error ceilings that invalidate the whole result if breached). 5. **Fixed horizon and decision rule** — exact `minSamplePerArm`/`minDurationMs` values, an explicit "do not read before `ready: true`, then freeze and apply once" rule, and an ordered, numbered decision rule (invalid/kill → ship treatment → ship control → no call) with concrete thresholds — not "when the effect looks significant." EXP-001 requires both an absolute 10-percentage-point difference **and** a 1.5× relative lift/drop before it will ship either arm; anything else is "no call," recorded as inconclusive, and the experiment is not extended or re-opened under a different rule. 6. **Rehearsal fixtures** — a small table of synthetic control/treatment counts with the decision each fixture must produce (known winner, no difference, harm), each including a multi-session user so the rehearsal proves per-`userId` dedup rather than per-session inflation. This is what a local rehearsal script asserts against before any real traffic exists. 7. **Immutable record** — a closing rule: do not alter hypothesis, arms, metrics, horizon, thresholds, or decision rule once an API experiment is created from this doc. If reality reveals a contract gap, the document is marked blocked and a **new numbered pre-registration** is written — the original is never edited in place to fit what happened. ## Freeze rules — why "no peeking" is enforced, not just requested The service is built with fixed-horizon guardrails, not sequential/always-valid inference — the standing rules restate this explicitly: experiments statistics stay fixed-horizon, no sequential inference. Concretely, from EXP-001's own rules (the pattern every pre-registration doc follows): - **No interim decisions.** Reading results before the API reports `ready: true` is not allowed to produce a product call — you can look, but you cannot ship or kill on what you see. - **Freeze after the first eligible read.** Once `ready: true` is reached, the pre-registered rule is applied **exactly once**. The experiment is not re-read, re-extended, or re-decided under a different rule afterward. - **Guardrail breach invalidates the read entirely.** SRM flagged, a guardrail failed, or either arm under its minimum sample makes the result **uninterpretable** — the rule says "make no product call... do not interpret conversion rates," not "be extra cautious." Delivery returns to `control` and the defect is logged, not shipped around. - **Immutable pre-registration.** See "Immutable record" above — the rule can't be rewritten once data exists, which is the actual mechanism that prevents a "significant-looking" post-hoc threshold from replacing the pre-registered one. ## Guardrails, restated for the operating view Make no call when sample-ratio mismatch is flagged, completion degrades beyond the pre-registered floor, or fallback/native-call errors breach the pre-registered limit. A dashboard chart informs the review; the dashboard's **Decide** action and `weir_experiment_decide` both apply the pre-registered rule mechanically — neither lets a human override that rule or read a verdict before the fixed horizon. --- # Review a flow This page is for a human, not an agent. Every other page on this site has a companion tool call — this one doesn't. Reviewing a flow and leaving comments happens in the dashboard (`packages/dashboard`), alongside the publish/rollback and experiment actions a person can also fire there (see [Why comments stay narrow](#why-comments-stay-narrow) below). Sign in to the dashboard the same way you would for [Monitoring](/docs/monitoring/) — with your Google account, or a bearer token for local/dev use. Then open **Previews** in the sidebar. > **Rough today (2026-07-31):** `weir release` publishes only > `config.json` plus `customAssets[]` (see [Concepts → Delivery model](/docs/concepts/#delivery-model)) > — no HTML artifact is uploaded on a normal release. Previewing a *live, released* v4 flow still > works: the server renders the preview on demand from the stored `config.json`, and the page marks > it with a visible **"Approximation preview"** banner. That render uses the same webview-era HTML > renderer the old bundles used, which has no way to draw a `custom` screen's real native > content — it shows as an empty card in this preview. Native rendering (WeirUI / the RN SDK) is the > real source of truth for how a `custom` screen actually looks; a native, screenshot-driven > replacement (real device screenshots uploaded by `weir walk`) is planned per PLAN.md M6 and will > replace this approximation once it ships. `weir release --draft` is unaffected either way (its own > `/publish-draft` route still uploads a real HTML build). ## How the preview is authorized The dashboard opens the preview in an iframe. A browser cannot attach a custom header to an iframe load. No browser lets an iframe `src` carry your sign-in token. The dashboard works around this with a short-lived **preview ticket**, never with your sign-in token. A preview ticket is scoped to one flow and one version. It expires 5 minutes after it is minted. Switching to a different flow or version needs a fresh ticket. The dashboard mints one automatically each time you switch — you never see this happen. **Never put your long-lived app token in a preview URL.** A token in a URL ends up in browser history. It ends up in anything a reviewer copies and pastes. It can end up in a Referer header. A ticket cannot cause any of that: it expires in minutes, and it only unlocks the one flow and version it was minted for. ### Calling the preview API directly Most integrations never need this section — the dashboard mints and uses tickets for you. Use this only from a script, a CLI, or your own tooling. `GET /preview/:flowId` accepts two different credentials: - **A bearer token.** The same per-app token you use for every other API call. Use this for programmatic or CLI access. Never expose this token in a URL a browser will load. - **A ticket**, passed as `?ticket=...`. Use this only when a browser loads the preview, exactly as the dashboard's iframe does. Mint a ticket by calling `POST /preview-tickets` with your bearer token: ``` curl -X POST https://api.agentwallie.com/preview-tickets \ -H "Authorization: Bearer $WEIR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"flowId": "onboarding"}' ``` The response is `{ "ticket": "...", "expiresAt": 1784999999999 }`. `expiresAt` is a Unix millisecond timestamp, about 5 minutes after the call. Pass the ticket back on the preview URL: ``` GET /preview/onboarding?ticket= ``` Omit `version` in the `POST /preview-tickets` body to mint a ticket for the flow's current latest published version. Pass a specific `version` to pin the ticket to that version instead. A ticket's version cannot be changed after it is minted — a `?version=` query parameter on the preview URL has no effect once a ticket is present, so mint a new ticket for a different version rather than editing the URL. ## The preview is the real bundle — or a labeled approximation A draft preview (`weir release --draft`) renders the exact HTML build that was uploaded — not a re-render, not a mock. Approving a draft preview means approving the artifact that ships. A released v4 flow's preview is different: see the callout above. It carries an "Approximation preview" banner and a stock screen's exact look. A `custom` screen's real native content is not in it. ## Pick a flow, a version, and an arm Three pickers sit across the top of the page. - **Flow.** Every flow this app has published. - **Version.** Every published version of that flow, newest first. - **Arm.** Only shown when the flow has a registered experiment. Pick the arm you want to see. Picking an arm does not fake the result. The dashboard finds a real user id that genuinely buckets into that arm under the same assignment function every device uses, and previews as that user would see it. If no id buckets into the arm you picked, the dashboard says so plainly instead of guessing. If the flow has no registered experiment, the arm picker is unavailable and the preview shows the flow unbucketed. ## Walk the flow The preview runs in a phone-sized frame (390×844, the viewport flows are authored against) on the left. Step through it the way a user would. ### Native actions are simulated A purchase, a restore, or a system permission prompt cannot happen for real on the web. When the flow calls one of these, a modal appears instead of the real system UI. - **Purchase.** Choose "Complete purchase," "Cancel," or "Fail" to see how the flow handles each outcome. - **Restore.** Choose a restored result or "nothing to restore." - **Permission.** Choose "Allow" or "Don't allow." Every modal states plainly that the outcome is simulated. **A simulated purchase is not a real transaction. It never touches StoreKit, and no money moves.** The same applies to a permission result — the modal's answer never asks the real OS for anything. Prices shown on a paywall screen in preview are sample values, clearly labeled as sample. A real price resolves from StoreKit on the device only; preview never bakes a real price into a bundle. A haptic call never shows a modal — it fires constantly in a real flow, so the dashboard shows a small indicator instead and moves on. Completing or dismissing the flow also shows an acknowledgment, with the reason and any variables the flow collected. ## Comment on a screen or an element Leave a comment three ways: - **On the whole flow.** Post from the composer with no screen selected. - **On the current screen.** Post while that screen is showing — the comment is scoped to it. - **On one element, Figma style.** Turn on "Comment on element," click the element, then write the comment. A pin marks where you clicked. ### Comments survive iteration An element-anchored comment does not depend on the element staying in exactly the same place. The dashboard tries several ways to find it again, from most to least specific, and falls back automatically. A comment is never dropped because an element moved, and it is never silently pinned to the wrong element. If none of those ways finds a match, the comment shows as **unanchored** in the panel, with its original text and the screen it came from. An unanchored comment still counts — it means "this element changed or moved, go find it," not "this feedback is gone." ### Comments carry across versions Reviewing version 3 also shows a still-open comment left on version 2, labeled with that origin version. Feedback nobody addressed yet does not disappear just because a new version shipped. ## Resolve a comment Every comment has a resolve toggle. Mark it resolved once it's addressed; reopen it if it wasn't. ## Why comments stay narrow [Monitoring](/docs/monitoring/#what-the-dashboard-can-change) describes the dashboard's real write surface: publish/rollback and the experiment lifecycle (Start/Stop/Decide). Flow *authoring* — composing or editing screen JSON — stays agent-first and repository-owned. A comment is not any of those. It is review metadata only: a note attached to a screen or an element. A comment cannot edit a flow's screens or branches, change an experiment's outcome or decision rule, or alter a published release artifact. The dashboard's operational buttons (publish/rollback, Start/Stop/Decide) are the write paths for those, and they run the same gated commands the agent surface does. Commenting adds no new write path to a flow's content, an experiment's verdict, or a release's artifact — it is review feedback, nothing more. ## Tenancy Your sign-in scopes you to one app. You cannot preview another app's flows, and you cannot read or write another app's comments — asking for either fails the same way asking for data that does not exist would. --- # Troubleshooting Each entry is one failure an unassisted integration actually hits, with the symptom, the cause, and the fix. They are drawn from real first-run friction, not hypotheticals. ## `weir_app_doctor` reports the `sdk-use` check as failed **Symptom.** `weir app-doctor --appRoot .` returns `ready: false` with a failed `flow::sdk-use` check: "adapter exists but no Weir.configure/present/WeirView call was found." **Cause.** The `integrationFile` declared in `weir/app.json` exists but the doctor cannot find one of the native entry-point names it knows. A stub should fail, but a pass only proves a matching symbol exists; it does not prove registry setup or a working presentation. **Fix.** For SwiftUI, verify the file really registers components, installs the generated registry, calls `Weir.configure`, and presents `WeirFlowView`/`Weir.present` with a v4 config root. For RN/Expo, the doctor recognizes `WeirCore.fetchConfig` and ``; also verify `WeirRegistry.setGeneratedRegistry` and the full device journey. For native Compose, verify the real `WeirFlow` journey rather than treating a source-symbol match as device evidence. ## `weir_app_doctor` refuses before running any check **Symptom.** The tool errors on the `config` check and runs nothing else. **Cause.** `weir/app.json` is missing, unparseable, or fails the schema. The doctor needs a valid config before it can check anything. **Fix.** Confirm the file exists at the `--appRoot` you passed and inspect the validation issues. The current parser expects `version: 1`, platform `ios|android|reactNative`, a non-empty `flows` array, and one of the documented `delivery.updateConfig` values. Do not change a valid app contract merely to silence a different check; fix the named path or field and rerun doctor. ## `weir_app_doctor` reports `baseline-config` or `baseline-freshness` **Symptom.** The doctor reports a missing, invalid, or stale native baseline config or asset. **Cause.** The directory declared by `flows[].baselineBundle` has no `config.json`, its flow id does not match, its normalized bytes differ from the current source spec, or a referenced `customAssets[].path` file is missing or stale. **Edit → verify loop.** Edit `weir/flows/.json`, then run `weir app-doctor --appRoot .`. The `baseline-freshness` check now fails because the embedded config no longer matches the flow. Run `weir embed --appRoot . --flowId ` to re-embed it, then re-run `weir app-doctor --appRoot .`; the check passes once the baseline matches again. **Fix.** Run `weir embed --appRoot . --flowId ` to rebuild the native baseline from the authoritative app contract, rerun doctor, then inspect the built app and prove the packaged config and assets render on a device. Do not add `index.html` or `manifest.json`; those are retired browser-preview artifacts, not native input. ## `weir_conform` looks hung **Symptom.** `weir conform` prints nothing for minutes; a short command timeout kills it. **Cause.** Conform runs a real headless-browser persona walk. Expected runtime is about **3–5 minutes** for a ten-screen flow. It is progressing, not hung. **Fix.** Do not set a timeout under about six minutes. Watch stderr — conform streams `screen N/total: ok` per screen as it walks, so you can confirm progress. It exits 0 only when every contract check passes. ## `weir_conform` fails on the event stream or a variable **Symptom.** Conform exits non-zero reporting an event-stream mismatch, an unset declared variable, or a native-feel check. **Cause.** The browser-preview artifact's behavior diverges from what the spec implies — a screen the walk cannot complete, a variable no screen sets, a literal currency string instead of a real `products.list` value, or network on the paint path. **Fix.** Run `weir walk` first (see [Sandbox preview & branch verification](/docs/sandbox-preview/)) and read `walk-report.json`: `reachedComplete` must be true and `variables` must have every declared variable set. Fix the spec so every variable is written on every path and every persona reaches `complete()`, then re-run conform. This proves the authoring contract, not the native app renderer; follow it with SwiftUI/RN component and device tests. ## `weir_release` fails — tell a credential error from a build error **Symptom.** `weir release` fails and it is unclear whether the flow or the server is at fault. **Cause.** `weir release` does two different things that fail two different ways: the local build/conform gate, and the publish call to the delivery API. **Fix.** Separate them. Run `weir release --appRoot . --flowId onboarding --dryRun` first — the dry run does the whole gate **except** the API call and needs no `WEIR_API_URL`/`WEIR_API_TOKEN`. If the dry run fails, it is a build/conform problem (fix the spec). If the dry run passes but the real release fails, it is a delivery problem: check the server is reachable, and that `WEIR_API_TOKEN` matches this app's id if the server enforces tokens. If no operator has issued a token yet, stop after the dry run — the server configuration is the token-issuance authority. For fully local runs you need neither variable; see [Run everything locally](/docs/local/). ## The kill switch does not revert the app to native **Symptom.** `weir_onboarding_enabled` is off in Remote Config, but the app still shows (or fails to show) the Weir flow. **Cause.** The kill switch lives on Firebase Remote Config by design, and its keys must match exactly. A mismatched or missing `weir_onboarding_enabled` / `weir_manifest_url` / `weir_manifest_public_key` / `weir_ingest_url` / `weir_ingest_token` breaks the revert path. **Fix.** Confirm all five Remote Config keys are present and spelled exactly as above, and that the app reads them at runtime — not from `weir/app.json`, which holds no secrets or endpoints. With the flag off, the app must return to `nativeFallback` without the delivery origin being reachable at all. If the flag is on but delivery is unavailable, the app-embedded v4 config is the SDK's offline config tier. See [Concepts → Kill switch](/docs/concepts/#kill-switch). ## Android app: a debug flag override never turns the flow on **Symptom.** You set an environment variable to force `weir_onboarding_enabled` on for a local proof, but the app ignores it. **Cause.** An `am`-launched Android app process (how `adb` and the launcher start your app) does **not** inherit adb-set environment variables, so `System.getenv(...)` can never be toggled from `adb` on a device or emulator. **Fix.** If the host app deliberately implements this testing seam, use an adb-settable system property, which the app process does see. Wire a debug-only override that reads `debug.weir_onboarding`, then force it on with: ``` adb shell setprop debug.weir_onboarding true ``` This is host-app code, not a promise of a supported native Compose package. Rebooting the emulator clears debug props, which is a quick way to confirm the app's override no longer wins. ## iOS: a local `http://localhost` fetch fails silently **Symptom.** The iOS demo app points at `http://localhost:8787` but never loads the config, with no clear error. **Cause.** Default App Transport Security blocks plaintext `http://`. The demo `Info.plist` has no `NSAppTransportSecurity` exception, so the request is denied before it leaves the app. **Fix.** Add an `NSExceptionDomains` block for `localhost` and `127.0.0.1` (with `NSExceptionAllowsInsecureHTTPLoads`) to the demo's `Info.plist`. Android already permits cleartext for local hosts and needs no change. See [Run everything locally](/docs/local/). ## The dashboard shows production data (or nothing) instead of your local server **Symptom.** You run the dashboard against a local API, but it shows the live box's data, or empty views. **Cause.** The dashboard's API base already defaults to `http://localhost:8787`, so this usually means something else is overriding it: a stale `localStorage` override from a previous session (under `weir_dashboard_api_base`), or `VITE_API_BASE` was set to the live API at build time (as a production build's own `.env.production` intentionally does). **Fix.** Check for a stale override first: in the running app, open "Advanced: sign in with a bearer token" and look at the "API base URL override (local dev only)" field — clear it or set it to `http://localhost:8787`. If you built with `VITE_API_BASE` set, rebuild without it (or set it to `http://localhost:8787` explicitly). See [Run everything locally](/docs/local/).