# 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: <screenId> 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:<id>" — 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 ("<namespace>.<name>"), 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
`"<namespace>.<name>"` 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:<id>"`:

```jsonc
{
  "customAssets": [
    { "id": "heroPlant", "path": "assets/hero-plant.jpg" }
  ]
}
```

- `id`: an identifier — reference it from a screen as `"custom:<id>"`.
- `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.
