@wireai/activation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. package/src/utils/readProgress.ts +33 -0
package/README.md ADDED
@@ -0,0 +1,923 @@
1
+ # wireai-onboarding
2
+
3
+ A premium, fully-themable **drop-in onboarding kit** on top of the open-source
4
+ [`wireai-rn`](https://github.com/chohra-med/wireai-rn) SDK. You give it a tenant
5
+ config; it runs an entire AI-driven, backend-orchestrated onboarding — themed
6
+ cards, a progress bar, a mid-flow value screen, per-step validation, and a
7
+ completion recap — behind one component.
8
+
9
+ > MIT-licensed. Install from npm as [`wireai-onboarding`](https://www.npmjs.com/package/wireai-onboarding), or consume **from source** for live kit-edit (as the apps in this repo do).
10
+
11
+ <div align="center">
12
+
13
+ [![npm](https://img.shields.io/npm/v/wireai-onboarding.svg)](https://www.npmjs.com/package/wireai-onboarding)
14
+ [![license](https://img.shields.io/npm/l/wireai-onboarding.svg)](LICENSE)
15
+
16
+ Created by [**Malik Chohra**](https://getwireai.com?utm_source=github&utm_medium=readme&utm_campaign=creator) · [Code Meet AI newsletter](https://codemeetai.substack.com?utm_source=github&utm_medium=readme&utm_campaign=newsletter)
17
+
18
+ Sponsored by [AI Mobile Launcher](https://aimobilelauncher.com?utm_source=github&utm_medium=readme&utm_campaign=sponsor) and [CasaInnov](https://casainnov.com?utm_source=github&utm_medium=readme&utm_campaign=sponsor)
19
+
20
+ </div>
21
+
22
+ ## Where it runs (the moving parts)
23
+
24
+ | Layer | Repo / path | Role |
25
+ |---|---|---|
26
+ | **SDK (OSS)** | `wireai-rn` | `WireAIProvider` + `ComponentRenderer` + `useWireAIThread`; renders whatever components the host registers; A2A transport. |
27
+ | **This kit** | `getwire_ai/wireai-onboarding` | `<WireOnboarding>` — themed cards, sparkle loader, countless stepper, `InterstitialCard` value screen, animated-checkmark completion, validators. **The single onboarding UI engine.** |
28
+ | **Backend** | `wire-rn/examples/dynamic-onboarding/server` | Multi-tenant LangGraph engine. Per-app `product_context` + `allowed_components`; validates every emission against `KNOWN_COMPONENTS`. Live on fly dev. |
29
+ | **Consumers** | Production B2C mobile apps | Each hosts `<WireOnboarding>` with its own theme + illustration registry. |
30
+
31
+ The kit's peers are `react`, `react-native`, `wireai-rn`, and
32
+ `react-native-safe-area-context` (>=4.0.0, for safe-area insets — the host must render a
33
+ `SafeAreaProvider` ancestor, standard in every Expo/RN app) — **no reanimated, no SVG, no
34
+ icon font.** Brand artwork is supplied by the host (see [Illustrations](#illustrations)).
35
+
36
+ ## How to include it in an app (end-to-end)
37
+
38
+ > Fastest path: run the **`wire-rn-integration`** Claude skill — it does all of the
39
+ > steps below automatically (detects your conventions, wires Metro, derives a theme,
40
+ > scaffolds the screen, type-checks). The manual steps, in order:
41
+
42
+ **0. Register the app on the backend → get a tenant key.** The kit talks to the
43
+ multi-tenant backend over A2A; the **`apiKey` is per-app and resolves the tenant
44
+ server-side**. Create the app in the getwireai **console** (or run the backend's
45
+ `register_<app>.py`) → it mints a `wai_…` key. Note the **server URL** (e.g. the fly
46
+ dev/prod URL). Nothing renders without these two.
47
+
48
+ **1. Install the package** (plus its peers):
49
+
50
+ ```bash
51
+ npm install wireai-onboarding wireai-rn
52
+ # react + react-native are peers (already in any RN/Expo app)
53
+ ```
54
+
55
+ > ⚠️ **EAS / cloud builds:** install from a **git URL or a published registry**, not a
56
+ > local `file:../...` path *outside the app repo* — EAS only uploads the app repo, so an
57
+ > external `file:` path fails to resolve in the cloud build. (Local `file:` is fine for
58
+ > on-device dev only.)
59
+
60
+ **2. Wire Metro** (one call — pins a single React/RN/wireai-rn/zod copy; a 2nd React
61
+ instance crashes RN):
62
+
63
+ ```js
64
+ const { getDefaultConfig } = require("expo/metro-config");
65
+ const { withWireOnboarding } = require("wireai-onboarding/metro");
66
+ module.exports = withWireOnboarding(getDefaultConfig(__dirname));
67
+ ```
68
+
69
+ **3. Config from env** (so you can flip backends without a code change):
70
+
71
+ ```
72
+ EXPO_PUBLIC_WIREAI_API_KEY=wai_…
73
+ EXPO_PUBLIC_WIREAI_SERVER_URL=https://…
74
+ EXPO_PUBLIC_WIREAI_APP_ID=my-app
75
+ ```
76
+ `wireConfigFromEnv({ appId })` reads these and returns `null` when keys are missing —
77
+ use that null as your gate (below).
78
+
79
+ **4. Load your brand fonts in the host.** The kit applies `theme.fonts.regular/medium/
80
+ bold` as **React Native `fontFamily` strings** — they only work if the host has loaded
81
+ those fonts (e.g. `expo-font` `useFonts`). If you don't load them, text falls back to
82
+ the system font. (The kit ships no fonts by design.)
83
+
84
+ **5. Theme it to match your app — including dark/light.** Either derive a palette from
85
+ one color (`themeFromBrand({ primary })`) or pass a full `Partial<OnboardingTheme>`
86
+ (colors + the font family names from step 4 + radius/spacing). To follow the app's
87
+ dark/light setting, build **two themes and pick by your theme state**, for example:
88
+
89
+ ```tsx
90
+ const theme = isDarkMode ? appThemeDark : appThemeLight; // each sets fonts:{regular:'YourFontRegular',…}
91
+ ```
92
+
93
+ **6. Render it + wire the continuation.** Drop `<WireOnboarding>` into your signup
94
+ flow (between username and the next step is typical). On `onComplete`, persist the
95
+ answers through your normal profile-update path, then navigate onward; on `onSkip`,
96
+ just navigate onward:
97
+
98
+ ```tsx
99
+ <WireOnboarding
100
+ config={config}
101
+ theme={theme}
102
+ illustrations={{ ...defaultIllustrations, ...myIllustrations }}
103
+ validators={{ username: checkUsernameAvailable }}
104
+ onComplete={(r) => { persist(r.answers); goNext(); }}
105
+ onSkip={goNext}
106
+ onError={fallBackToStaticOnboarding}
107
+ onEvent={analytics}
108
+ />
109
+ ```
110
+
111
+ **7. Gate it so you can turn it on/off without a release.** Wrap the screen behind a
112
+ flag (env var for dev, **remote config** for production) **AND** the `config != null`
113
+ check, so a half-set env or a backend outage never routes a user into a dead screen —
114
+ fall through to your existing onboarding instead. Decide *who* sees it (e.g. new
115
+ signups only) in your own navigation logic.
116
+
117
+ **8. Make any new cards emittable on the backend.** Registering a card in this kit is
118
+ not enough — the server validates every emission. See [Backend coupling](#backend-coupling).
119
+
120
+ **No native rebuild for the kit itself** (it's pure JS). A rebuild is only needed if
121
+ *your* gating/flag mechanism uses a native module (e.g. Firebase Remote Config).
122
+
123
+ ## Installation
124
+
125
+ The kit ships **compiled output** (`dist/`, ESM + CJS + types) for external apps,
126
+ **and** still resolves to `src/` for the monorepo apps that consume it from source
127
+ — driven by the `react-native` export condition + each app's tsconfig path alias.
128
+
129
+ ### 1. External apps (install from npm)
130
+
131
+ Install the package and wire Metro with the one-call helper:
132
+
133
+ ```bash
134
+ # from npm (public)
135
+ npm install wireai-onboarding
136
+ # …or from a tarball (see Distribution below)
137
+ npm install ./wireai-onboarding-0.1.3.tgz
138
+ # …or over SSH
139
+ npm install git+ssh://git@github.com/<org>/wireai-onboarding.git
140
+ ```
141
+
142
+ ```js
143
+ // metro.config.js — replaces the hand-written ~25-line block
144
+ const { getDefaultConfig } = require("expo/metro-config");
145
+ const { withWireOnboarding } = require("wireai-onboarding/metro");
146
+
147
+ module.exports = withWireOnboarding(getDefaultConfig(__dirname));
148
+ ```
149
+
150
+ ```tsx
151
+ // 3-line minimal usage — themed from a brand color, config from env, default art
152
+ import { WireOnboarding, themeFromBrand, wireConfigFromEnv, defaultIllustrations } from "wireai-onboarding";
153
+
154
+ export function Onboarding() {
155
+ const config = wireConfigFromEnv({ appId: "my-app" }); // null if env keys missing
156
+ if (!config) return null; // …or render your static onboarding
157
+ return (
158
+ <WireOnboarding
159
+ config={config}
160
+ theme={themeFromBrand({ primary: "#15B0B0" })}
161
+ illustrations={defaultIllustrations}
162
+ onComplete={(r) => persist(r.answers)}
163
+ />
164
+ );
165
+ }
166
+ ```
167
+
168
+ `wireConfigFromEnv` reads `EXPO_PUBLIC_WIREAI_API_KEY` / `EXPO_PUBLIC_WIREAI_SERVER_URL`
169
+ (+ optional `EXPO_PUBLIC_WIREAI_APP_ID`) and returns `null` when keys are missing, so
170
+ you gate the AI flow on one falsy check. `themeFromBrand` derives a full palette from
171
+ your primary color; spread your own art over `defaultIllustrations` to override by
172
+ name: `illustrations={{ ...defaultIllustrations, ...myIllustrations }}`.
173
+
174
+ ### 2. Source consumers (your monorepo apps)
175
+
176
+ Same helper, with `source` pointed at the kit's `src/` (adds it to `watchFolders`
177
+ and maps the `wireai-onboarding` specifier to source). Keep the tsconfig path alias.
178
+
179
+ ```js
180
+ const path = require("path");
181
+ const { getDefaultConfig } = require("expo/metro-config");
182
+ const { withWireOnboarding } = require("wireai-onboarding/metro");
183
+
184
+ module.exports = withWireOnboarding(getDefaultConfig(__dirname), {
185
+ source: path.join(__dirname, "../../getwire_ai/wireai-onboarding/src"),
186
+ });
187
+ ```
188
+
189
+ `withWireOnboarding(config, options)` is non-destructive: it pins `react` /
190
+ `react-native` / `wireai-rn` / `zod` to the app's own copy (a 2nd React instance
191
+ crashes RN), merges with any `extraNodeModules` / `watchFolders` you already set, and
192
+ returns the same config. `options.appRoot` defaults to `process.cwd()`.
193
+
194
+ > The `"react-native"` export condition resolves `wireai-onboarding` to `src/index.ts`
195
+ > for Metro (it transforms the kit's source), while `import`/`require` resolve to the
196
+ > built `dist`. So both npm consumers and source consumers work; the dist build is additive.
197
+
198
+ ## Distribution
199
+
200
+ `npm run pack:tarball` runs the build (via `prepack`) and produces
201
+ `wireai-onboarding-x.y.z.tgz` containing `dist` + `src` + `metro` + README +
202
+ CHANGELOG. The primary channel is **npm** (`npm install wireai-onboarding`); a
203
+ tarball (`npm install ./wireai-onboarding-x.y.z.tgz`) or a `git+ssh://…` install
204
+ work too for pre-publish or pinned builds.
205
+
206
+ ## Quick start
207
+
208
+ ```tsx
209
+ import { WireOnboarding } from "wireai-onboarding";
210
+
211
+ <WireOnboarding
212
+ config={{ apiKey, serverUrl, appId: "my-app" }}
213
+ theme={myBrandTheme}
214
+ illustrations={{ momentum: <MyMomentumSvg /> }}
215
+ validators={{ username: checkUsernameAvailable }}
216
+ onComplete={(result) => persist(result.answers)}
217
+ onSkip={skip}
218
+ onError={() => fallBackToStaticOnboarding()}
219
+ onEvent={(e) => analytics(e)}
220
+ />
221
+ ```
222
+
223
+ ### `<WireOnboarding>` props
224
+
225
+ | Prop | Type | Notes |
226
+ |---|---|---|
227
+ | `config` | `{ apiKey, serverUrl, appId, metadata?, appVersion? }` | A2A transport; the key resolves the tenant server-side. `appVersion` is host-injected (e.g. `Constants.expoConfig?.version`) and forwarded for analytics segmentation. **Required.** |
228
+ | `onComplete` | `(result: { answers, raw }) => void` | Fires when the user taps the terminal recap's CTA. **Required.** |
229
+ | `theme` | `Partial<OnboardingTheme>` | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
230
+ | `illustrations` | `Record<string, ReactNode>` | App artwork for `InterstitialCard`, keyed by name. |
231
+ | `validators` | `Record<string, StepValidator>` | Per-step, keyed by base-question key (e.g. `username`). Blocks advance + shows an inline error. |
232
+ | `onSkip` | `() => void` | User skipped. |
233
+ | `onError` | `(err) => void` | Backend error/timeout — host owns recovery (e.g. route to a static flow). Without it, the kit shows an inline retry. |
234
+ | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `turn` / `error` — recover per-turn analytics since the kit owns the loop. |
235
+ | `copy` | `Partial<OnboardingCopy>` | Localize the kit's built-in English loader/completion strings. |
236
+ | `approxScreens` | `number` | Approx total screens — paces the bar as `step/total` (capped, never shown). A backend `progress.total` (the screen budget, sent every turn incl. init) wins, so usually unnecessary. |
237
+ | `components` | `WireAIComponent[]` | Override the registered cards (defaults to `onboardingComponents`). |
238
+ | `startMessage` | `string` | First message to the backend. Default `"start"`. |
239
+ | `startTimeoutMs` | `number` | Watchdog for the first card. Default `15000`. |
240
+ | `storage` | `WireOnboardingStorage` | Host-injected storage (AsyncStorage-compatible `getItem/setItem/removeItem`) for **session-id persistence**: an app KILL mid-onboarding resumes the SAME backend session instead of minting a new one, so the funnel's `started` count stays honest (no phantom drops). Pass AsyncStorage as-is, or a 3-line MMKV wrapper. Omit for the previous per-mount behavior. Persists the kit's own correlation seed only — answers stay the host's job via `onComplete`. |
241
+ | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
242
+ | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
243
+ | `userContext` | `Record<string, string \| number \| boolean>` | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. **No PII** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). |
244
+ | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
245
+
246
+ ## Helpers (the reusable substrate)
247
+
248
+ You rarely hand-roll config, gating, analytics, or attribution — the kit ships them:
249
+
250
+ | Helper | Use |
251
+ |---|---|
252
+ | `wireConfigFromEnv({ appId, metadata? })` | Reads `EXPO_PUBLIC_WIREAI_API_KEY`/`_SERVER_URL`/`_APP_ID` → a `config` (or `null` to gate). |
253
+ | `isOnboardingEnabled({ remote? })` | The one gate: transport present (apiKey + serverUrl). Optional `remote` kill-switch to disable without a release; no env flag. |
254
+ | `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(e)` | Map a kit `OnboardingEvent` to a canonical `wire_onboarding_*` `{ name, params }`; log it through your own analytics. |
255
+ | `attributionMetadata(a)` | Shape install/ad attribution into `{ attribution }` for `config.metadata` (forwarded into every backend request). |
256
+ | `reportClientEvent` / `reportClientEvents` / `makeSessionId` | Report device-only funnel events to `POST /v1/events`. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, and `client_fallback` when the AI flow degrades to the static fallback (so the dashboard's fallback-rate counts the whole-flow case). The host must NOT also report its own fallback. |
257
+ | `identifyOnboarding(opts)` / `sanitizeUserId` / `USER_ID_MAX_LENGTH` | Bind a `userId` to a session AFTER the flow (post-registration). `identifyOnboarding` resolves the session from an explicit `contextId` (captured from the `started`/`resumed` `onEvent`) or the persisted `storage`, then posts an `identify` event. `sanitizeUserId` trims/caps an id; `USER_ID_MAX_LENGTH` is the 128-char cap. See [User identity](#user-identity). |
258
+ | `deriveAnswers(messages)` | Deterministic `{ key: value }` from the thread (no second LLM call). |
259
+ | `themeFromBrand({ primary })` · `defaultIllustrations` | One-color theme; dependency-free fallback artwork. |
260
+ | `motionSpec` | The motion constants behind every kit animation (durations, springs, the design ease). Read-only for hosts. `setMotionDurationScale` / `getMotionDurationScale` are QA tools for the playground's animation-speed knob: never call them in production code. |
261
+
262
+ > **`onEvent`** fires `started` / `resumed` / `turn` / `error` / `retry` / `fallback` (the kit owns the loop, so this is how you recover per-turn analytics). `resumed` fires INSTEAD of `started` when a persisted session was restored (see the `storage` prop) — don't count both as flow starts. Completion is signalled via `onComplete`, not `onEvent` — log `WIRE_ONBOARDING_EVENTS.completed` there. `started` and `resumed` also carry a `contextId` (the A2A session id): capture it if you might bind a `userId` after the flow finishes (see [User identity](#user-identity)).
263
+
264
+ **Integrating with an AI agent?** See [`INTEGRATION_PROMPT.md`](./INTEGRATION_PROMPT.md) (copy-paste prompt for Claude Code) and [`llms.txt`](./llms.txt). Fastest of all: the `wire-rn-integration` Claude skill.
265
+
266
+ ## Device & user context
267
+
268
+ Two optional, host-injected inputs let onboarding analytics segment the funnel by device and by what your app already knows about the user. The kit collects the device snapshot itself (no dependency added); everything else you pass.
269
+
270
+ - **`config.appVersion`** (string): your app version, e.g. `Constants.expoConfig?.version`. The kit reads nothing to get it, so it stays dependency-free; you inject it.
271
+ - **`userContext`** (`Record<string, string | number | boolean>`): non-PII context the app already has, like signup method, referral source, plan tier, or a hashed user id. Same host-injection idea as `storage`. Do not put raw emails, names, or phone numbers here; pass a hash if you need a user key. Values are primitives only, and the server caps key count/size and drops deep nesting.
272
+
273
+ Both ride the A2A session-start metadata AND every client event (`dropped`, `client_fallback`). Old servers ignore the extra fields, so it is backward compatible.
274
+
275
+ ### What `collectDeviceContext()` collects
276
+
277
+ Only React Native built-ins and the standard `Intl` global, each read defensively (missing fields are omitted, it never throws):
278
+
279
+ | Field | Source |
280
+ |---|---|
281
+ | `platform` | `Platform.OS` |
282
+ | `osVersion` | `Platform.Version` / iOS `osVersion` / Android `Release` |
283
+ | `brand`, `model` | Android `Platform.constants` |
284
+ | `interfaceIdiom` | iOS `Platform.constants` |
285
+ | `formFactor` | `phone` / `tablet` (iOS idiom, else shortest screen side >= 600dp) |
286
+ | `screenWidth`, `screenHeight`, `screenScale` | `Dimensions.get('screen')` |
287
+ | `isRTL` | `I18nManager.isRTL` |
288
+ | `locale`, `timeZone` | `Intl.DateTimeFormat().resolvedOptions()` (guarded for Hermes) |
289
+ | `appVersion` | host-injected from `config.appVersion` |
290
+
291
+ ### No tracking (privacy-label-neutral)
292
+
293
+ No advertising IDs (no IDFA/GAID), no `getUniqueId`, no fingerprinting APIs. Nothing here identifies a user or device uniquely, so adopting it does not change your App Privacy or Data Safety declarations, and it adds zero dependencies. On the backend the server derives a coarse country from the sent locale/timezone only, and never processes or stores IP addresses.
294
+
295
+ ## User identity
296
+
297
+ `userContext` segments the funnel; **`userId`** goes one step further and lets you reconcile an onboarding session to a real user later (console sessions to your own user table, or GA4 users). It is an OPAQUE, PSEUDONYMOUS string that YOU own, your internal user id, **not** an email, name, or phone number. Pass a hash if your only key is an email. The kit trims it and caps it at `USER_ID_MAX_LENGTH` (128) chars; a longer id is truncated, never rejected. Old servers ignore the extra field, so it stays backward compatible.
298
+
299
+ Users often register during or after onboarding, so `userId` is fully optional and can arrive late. There are three binding moments:
300
+
301
+ **1. Known at mount.** Pass it as a prop and it rides the A2A session-start metadata; the server binds it when it creates the session.
302
+
303
+ ```tsx
304
+ <WireOnboarding config={config} userId={currentUser?.id} onComplete={persist} />
305
+ ```
306
+
307
+ **2. The user registers mid-session.** Change the `userId` prop and the kit emits an `identify` client event that attaches the id to the LIVE session. Nothing else to call; re-render with the new value.
308
+
309
+ ```tsx
310
+ const [userId, setUserId] = useState<string | undefined>();
311
+ // ...the user signs up mid-flow:
312
+ setUserId(newUser.id); // the kit fires `identify` and binds the running session
313
+ ```
314
+
315
+ **3. The user registers after the flow.** Capture the `contextId` from the `started` (or `resumed`) `onEvent` while onboarding runs, then call `identifyOnboarding(...)` once you have the id. Completion clears the persisted session, so the captured `contextId` is the reliable handle.
316
+
317
+ ```tsx
318
+ import { identifyOnboarding } from "wireai-onboarding";
319
+
320
+ const contextIdRef = useRef<string>();
321
+
322
+ <WireOnboarding
323
+ config={config}
324
+ onEvent={(e) => {
325
+ if (e.type === "started" || e.type === "resumed") contextIdRef.current = e.contextId;
326
+ }}
327
+ onComplete={persist}
328
+ />;
329
+
330
+ // later, once the user registers:
331
+ await identifyOnboarding({ config, userId: newUser.id, contextId: contextIdRef.current });
332
+ ```
333
+
334
+ If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws; it resolves `true` when it dispatched an `identify` and `false` when it could not (no user id, no server url, or no resolvable session).
335
+
336
+ **No PII.** `userId` is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as `userContext`.
337
+
338
+ ## Session mapping (know when a user opens the app again)
339
+
340
+ Onboarding runs once, on the first launch. Session mapping is the other half: every time the
341
+ user opens the app, the kit posts one standard `app.session_started` event so the backend knows
342
+ who came back, when, and for the how-many-th time. That single event does two jobs at once, and
343
+ it needs **no new server endpoint**, because it flows into the SAME event stream the console and
344
+ the decision engines already read.
345
+
346
+ **The contract.** Each app-open posts one event to `POST {serverUrl}/v1/events`:
347
+
348
+ ```jsonc
349
+ {
350
+ "event_type": "app_event",
351
+ "question_key": "app.session_started", // the name a trigger matches
352
+ "session_id": "wire_lz1_9fk2a0", // a PER-OPEN id, NOT the onboarding contextId
353
+ "user_id": "u_42", // your opaque id; omitted on a pre-auth open
354
+ "user_context": {
355
+ "device_key": "dev_abc", // stable, non-PII; groups this device's opens
356
+ "session_count": 3, // your local open counter for this open
357
+ "returning": true, // derived from session_count > 1
358
+ "app_version": "1.4.2",
359
+ "platform": "ios"
360
+ }
361
+ }
362
+ ```
363
+
364
+ The name is carried as `question_key` on an `app_event` (not as the `event_type`), because that
365
+ is the shape the server matches a trigger's `event` against and the shape it stores in the app.
366
+ namespace. `device_key` rides in the non-PII `user_context` bucket, where the server reads it to
367
+ group a device's sessions. The server fills `app_id` and `environment` from your key, so you
368
+ never send them.
369
+
370
+ **The per-open session id is not the onboarding contextId.** Onboarding owns one context id for
371
+ its one run. Session mapping fires on every open, so it mints its own fresh `session_id` each
372
+ time. Grouping a user or device over time is done by `user_id` and `device_key`, never by
373
+ `session_id`.
374
+
375
+ **Pre-auth to post-auth.** A launch before the user signs in sends `device_key` with no
376
+ `user_id`, so it is a valid device-only session. Once the user authenticates, later opens carry
377
+ both `user_id` and `device_key`, which links the earlier anonymous opens to the user. (The same
378
+ `device_key` also joins the onboarding session once `identifyOnboarding` or a session event
379
+ carries both ids.)
380
+
381
+ **Two ways to wire it. Both are first-class.**
382
+
383
+ If your app already keeps a session counter (Myelino and Morrow do), call the plain function
384
+ from your own "app opened" path and pass the counter value:
385
+
386
+ ```tsx
387
+ import { reportSessionStart } from "wireai-onboarding";
388
+
389
+ // In your app-open effect (or wherever you bump your session counter):
390
+ reportSessionStart({
391
+ target: { serverUrl: config.serverUrl, apiKey: config.apiKey },
392
+ userId: currentUser?.id, // omit before auth
393
+ deviceKey, // your stable non-PII device id
394
+ sessionCount, // your local counter value for this open
395
+ appVersion, // optional
396
+ });
397
+ ```
398
+
399
+ If you do NOT have a counter, use the hook. It fires on mount and again whenever the app returns
400
+ to the foreground after at least 30 minutes in the background (a real new open, not a quick
401
+ app-switch):
402
+
403
+ ```tsx
404
+ import { useSessionStart } from "wireai-onboarding";
405
+
406
+ useSessionStart(
407
+ { serverUrl: config.serverUrl, apiKey: config.apiKey, appVersion },
408
+ { userId: currentUser?.id, sessionCount, deviceKey },
409
+ );
410
+ ```
411
+
412
+ Both paths are fire-and-forget: they never throw into the UI, never block, and swallow a dead
413
+ endpoint. A once-per-open guard means a re-render can never double-fire the same open.
414
+
415
+ **The payoff.** Because the event lands in the shared stream, the existing decision engines act
416
+ on it with zero extra code:
417
+
418
+ - **Fire the questionnaire on the user's Nth session.** Set the questionnaire's `min_sessions`
419
+ to `N`. The server counts DISTINCT opens grouped by `device_key`, so it fires on the Nth open.
420
+ - **Fire on a signal within one open.** A trigger `{ event: "app.session_started", min_count: 1 }`
421
+ confirms the current open started before the popup shows.
422
+ - **Per-user retention analytics.** The `user_id` plus `session_count` plus `returning` on every
423
+ open is exactly the "when did this user last use the app, and how often" data the console reads.
424
+
425
+ ## Testing the flow (no account) — `DemoOnboarding`
426
+
427
+ Re-running onboarding normally needs a fresh signup each time (it only shows for new
428
+ users). For dev/QA, drop in **`DemoOnboarding`** — an on-demand trigger that opens the
429
+ flow in a modal, runs it once, and closes (no loop, no account, no navigation side
430
+ effects). Put it behind `__DEV__` anywhere — a Settings row is typical:
431
+
432
+ ```tsx
433
+ import { DemoOnboarding, wireConfigFromEnv } from "wireai-onboarding";
434
+
435
+ {__DEV__ && (
436
+ <DemoOnboarding
437
+ config={wireConfigFromEnv({ appId: "my-app" })} // null when Wire AI isn't configured
438
+ theme={myTheme}
439
+ illustrations={myIllustrations}
440
+ label="Demo onboarding (dev)"
441
+ />
442
+ )}
443
+ ```
444
+
445
+ If `config` is `null` (the app hasn't integrated Wire AI yet) it shows a "not configured"
446
+ hint instead of the flow. By default the demo does **not** persist — pass `onComplete` to
447
+ observe the captured answers, or `renderTrigger={(open) => <YourButton onPress={open} />}`
448
+ to use your own button.
449
+
450
+ ## Cards
451
+
452
+ `onboardingComponents` registers: `TextInputCard`, `SelectionCard`,
453
+ `ChipSelectCard`, `NumberStepperCard`, `StatusCard` (terminal recap), and
454
+ **`InterstitialCard`** — a mid-flow momentum/value screen (Duolingo/Cal-AI style):
455
+ an illustration + a line that reflects the user's answers back + a Continue tap.
456
+ It is not a question; the backend emits it once mid-flow.
457
+
458
+ ### Illustrations
459
+
460
+ A tenant can configure **multiple** growth images per app (name + description +
461
+ image URL) in the dashboard; the onboarding AI picks the one whose description best
462
+ fits each user, and the backend resolves the chosen name to an `imageUrl`. On the
463
+ device, `InterstitialCard` prefers a host-supplied `illustrations` registry node for
464
+ that name (so an app can ship brand SVG/Lottie the kit never imports), and otherwise
465
+ renders the backend `imageUrl` via RN `<Image>`. Leave a dashboard image's URL empty
466
+ to mean "the app provides this one by name."
467
+
468
+ ```tsx
469
+ illustrations={{
470
+ "before-after": <BeforeAfterIllustration width={300} />, // your SVG component
471
+ momentum: <MomentumGlyph />,
472
+ }}
473
+ ```
474
+
475
+ ## Backend coupling
476
+
477
+ The backend (`wire-rn/examples/dynamic-onboarding/server`) is the source of truth
478
+ for what the agent may emit. To make a new card emittable you must, server-side:
479
+
480
+ 1. add it to `app/wire.py` `KNOWN_COMPONENTS` + `COMPONENT_DOCS`, **and**
481
+ 2. add its name to the tenant's `allowed_components` (the `register_*.py` script),
482
+
483
+ then `fly deploy`. Registering the card in this kit alone is **not** enough — the
484
+ server validates every emission and replaces unknown components with a safe
485
+ `StatusCard`. (`InterstitialCard` is already wired on both sides.)
486
+
487
+ ## Consuming from source (Metro + tsconfig)
488
+
489
+ The monorepo apps resolve the kit from source. In each app:
490
+
491
+ - **`tsconfig.json`** → `"paths": { "wireai-onboarding": ["<rel>/wireai-onboarding/src/index.ts"], "wireai-onboarding/*": ["<rel>/wireai-onboarding/src/*"] }`
492
+ - **`metro.config.js`** → use the `withWireOnboarding` helper with `source` (see
493
+ [Installation → source consumers](#2-source-consumers-your-monorepo-apps)). It
494
+ watches the kit `src`, maps the `wireai-onboarding` specifier to it, and pins
495
+ `react` / `react-native` / `wireai-rn` / `zod` to the app's own `node_modules`
496
+ (a second React instance crashes RN) — the same wiring the apps spell out by hand.
497
+
498
+ Consuming apps wire this in one line via `withWireOnboarding(getDefaultConfig(__dirname))`
499
+ in their `metro.config.js` (npm mode); pass `{ source }` only for local source consumption.
500
+
501
+ ## Conventions (for new cards)
502
+
503
+ Per `wire-rn/CONTRIBUTING.md`: `React.memo` + `useCallback`, the submitted-state
504
+ pattern (disable after first interaction), a Zod `propsSchema` with `.describe()`
505
+ on **every** field, a `description` written as an LLM routing instruction, and
506
+ `StyleSheet.create` (no inline styles, no `any`). `tsconfig` is `strict` +
507
+ `noUncheckedIndexedAccess`. Typecheck with `npm run typecheck` (or through a
508
+ consuming app, since the kit has no local TS install).
509
+
510
+ ## Coachmarks & guided tours
511
+
512
+ The kit ships a performance-first guided-tour engine on a **subpath** so the main
513
+ barrel stays dependency-free — import it from `wireai-onboarding/coachmarks`. It
514
+ pulls in two **optional** peers only when you use it: `react-native-reanimated`
515
+ (UI-thread gesture + ring animation) and `expo-blur` (the frosted spotlight).
516
+ Source consumers get it through the existing `wireai-onboarding/*` tsconfig path
517
+ mapping; installed apps resolve the subpath export.
518
+
519
+ The split: **the kit owns the animation/blur/ring/measure/queue**, the **app
520
+ declares WHERE things anchor** (a coachmark id → an on-screen target) and **WHICH
521
+ tour plays**. AI-chosen ordering is a later, drop-in phase (see the feature map
522
+ below) — nothing to rewire.
523
+
524
+ **1. Mount the provider once, around your `NavigationContainer`.** It renders the
525
+ overlay host as a root sibling so a ring can paint _above_ the bottom tab bar
526
+ (which react-navigation draws over screen components). It takes a **synchronous**
527
+ storage adapter — gate reads must not be async or a ring flashes before the
528
+ "already seen" check resolves. An MMKV wrapper is three lines:
529
+
530
+ ```tsx
531
+ import { CoachmarkProvider } from "wireai-onboarding/coachmarks";
532
+ import { storage } from "./mmkv"; // your MMKV instance
533
+
534
+ const coachmarkStorage = {
535
+ getItem: (k: string) => storage.getString(k) ?? null,
536
+ setItem: (k: string, v: string) => storage.set(k, v),
537
+ };
538
+
539
+ <CoachmarkProvider
540
+ storage={coachmarkStorage}
541
+ accentColor="#3BA9AE" // defaults to the onboarding theme's `primary`
542
+ isTestingCoachmark={__DEV__} // QA flag — see below
543
+ >
544
+ <NavigationContainer>{/* … */}</NavigationContainer>
545
+ </CoachmarkProvider>;
546
+ ```
547
+
548
+ **2. Make any element ringable with `useCoachmarkAnchor(id)`.** Attach the ref to
549
+ a plain `View` wrapping the target (`collapsable={false}` so the native node
550
+ survives measurement). Pass `null` to register nothing (e.g. only the first row
551
+ of a list):
552
+
553
+ ```tsx
554
+ import { useCoachmarkAnchor } from "wireai-onboarding/coachmarks";
555
+
556
+ const groupTab = useCoachmarkAnchor("group_tab");
557
+ <View ref={groupTab} collapsable={false}>{tabButton}</View>;
558
+ ```
559
+
560
+ **3. Play an ordered tour with `useCoachmarkTour(steps, options)`.** Gating lives
561
+ in the kit — it reads/writes `wire_coachmark_<tourId>_seen` through the injected
562
+ storage, so the app keeps only its own domain gates. Steps must be a memoized
563
+ array; step callbacks are held in a ref (parent re-renders never re-fire a step):
564
+
565
+ ```tsx
566
+ import { useCoachmarkTour } from "wireai-onboarding/coachmarks";
567
+
568
+ const steps = useMemo(
569
+ () => [
570
+ { id: "feed_scroll", message: "Swipe to explore.", gesture: "swipe_up" },
571
+ { id: "group_tab", message: "Your groups live here.", anchorId: "group_tab",
572
+ gesture: "tap", onEngage: () => navigation.navigate("Groups") },
573
+ ],
574
+ [navigation],
575
+ );
576
+
577
+ useCoachmarkTour(steps, {
578
+ tourId: "home_tour", // gate key + analytics prefix
579
+ enabled: hasPosts, // domain gate — arm only when it makes sense
580
+ showOnce: true, // default; persisted via the gate
581
+ startDelayMs: 3000, // let passive UI land first
582
+ onComplete: () => {},
583
+ onStepShown: (id) => analytics.track("coachmark_shown", { id }),
584
+ onStepEngaged: (id) => analytics.track("coachmark_engaged", { id }),
585
+ onStepDismissed: (id) => analytics.track("coachmark_dismissed", { id }),
586
+ });
587
+ ```
588
+
589
+ Analytics stay callback-based (no analytics dep in the kit). Reduce Motion is
590
+ honored (the gesture glyph renders static, no motion loop); the blur mounts only while a step is visible and
591
+ costs nothing at rest; only one overlay is ever active.
592
+
593
+ **Dismissal advances — it doesn't quit.** Tapping the ring or tooltip engages the step (fires `onStepEngaged`) and moves on; tapping the dimmed backdrop advances to the **next** step (fires `onStepDismissed`) instead of killing the tour, and only a backdrop tap on the **last** step ends it. A stray backdrop tap mid-tour never drops the remaining steps.
594
+
595
+ **QA replay — `isTestingCoachmark`.** Set it `true` on the provider (or call
596
+ `setCoachmarkTesting(true)`) and every seen-gate reads as unseen while every
597
+ seen-write is suppressed — so every tour _and_ showcase replays on each mount.
598
+ One boolean re-sees the whole surface.
599
+
600
+ ### The feature map (declare it now; AI chooses from it later)
601
+
602
+ Declare your app's coachmarks as a single **catalog** — the anchor ids, the copy,
603
+ and where each lives. Today the whole catalog plays in declared order. Later, the
604
+ Wire server emits an ordered `coachmarks: string[]` id list chosen by the user's
605
+ captured intent; you pass it straight to `selectTourSteps(catalog, selection)` and
606
+ nothing else changes — same contract, AI is a drop-in:
607
+
608
+ ```ts
609
+ import { selectTourSteps } from "wireai-onboarding/coachmarks";
610
+
611
+ // catalog = your feature map; each entry has a stable `id`
612
+ const catalog = [
613
+ { id: "feed_scroll", message: "Swipe to explore.", gesture: "swipe_up" },
614
+ { id: "post_location", message: "Tap a place to open it in Maps.", anchorId: "post_location", gesture: "tap" },
615
+ { id: "group_tab", message: "Your groups live here.", anchorId: "group_tab", gesture: "tap" },
616
+ ];
617
+
618
+ const steps = selectTourSteps(catalog); // Phase 1-2: full, declared order
619
+ const aiSteps = selectTourSteps(catalog, plan.coachmarks); // Phase 3: AI subset + order
620
+ ```
621
+
622
+ `selectTourSteps` returns the catalog unchanged when `selection` is absent, and
623
+ filters + orders it by the selection ids when present (unknown ids skipped,
624
+ duplicates ignored).
625
+
626
+ ## Feature showcase (the personalized value bridge)
627
+
628
+ `wireai-onboarding/showcase` wraps [`@blazejkustra/react-native-onboarding`](https://github.com/software-mansion-labs/react-native-onboarding)
629
+ (the **optional** peer) as a small deck of declarative slides. You supply a
630
+ `ShowcaseConfig`; the kit maps it to the package's API, bakes in the onboarding
631
+ theme colors, and gates once through the same storage as the tours
632
+ (`wire_showcase_<id>_seen`; `isTestingCoachmark` bypasses it). If already seen it
633
+ renders nothing and calls `onDone` from an effect.
634
+
635
+ **Recommended placement (v0.3.0+): AFTER `onComplete`, as a personalized value
636
+ bridge.** The user just told you what they want in `result.answers`, so the
637
+ terminal recap is the highest-value slot to show the 2-3 slides that answer it,
638
+ ordered most-relevant first ("you said you want X, here's how the app does X", the
639
+ Duolingo / Headspace pattern). Select them from the answers with
640
+ `selectShowcaseSlides`:
641
+
642
+ ```tsx
643
+ import { FeatureShowcase, selectShowcaseSlides } from "wireai-onboarding/showcase";
644
+
645
+ // The app declares the full slide catalog once (stable ids):
646
+ const catalog = [
647
+ { id: "feed", title: "Autoplay feed", description: "Swipe through places.", image: require("./intro/feed.png") },
648
+ { id: "groups", title: "Groups", description: "Plan together.", image: require("./intro/groups.png") },
649
+ { id: "saved", title: "Saved", description: "Everything you liked, in one place.", image: require("./intro/saved.png") },
650
+ ];
651
+
652
+ // Sequence: WireOnboarding → onComplete(result) → select from answers → FeatureShowcase → app
653
+ <WireOnboarding
654
+ onComplete={(result) => {
655
+ persist(result.answers);
656
+ setSlides(selectShowcaseSlides(catalog, idsFromAnswers(result.answers)));
657
+ }}
658
+ />;
659
+
660
+ <FeatureShowcase
661
+ config={{ id: "app_feature_showcase", slides }}
662
+ accentColor="#3BA9AE"
663
+ onDone={() => goToApp()}
664
+ />;
665
+ ```
666
+
667
+ `selectShowcaseSlides(catalog, selection?)` mirrors `selectTourSteps` exactly:
668
+ returns the catalog unchanged when `selection` is absent (full deck, declared
669
+ order), and filters + orders it by the selection ids when present (unknown ids
670
+ skipped, duplicates ignored). `idsFromAnswers` is the host's own pure map from its
671
+ answer keys/values to slide ids. Keep it to 2-3, and fall back to the full deck
672
+ when answers are thin.
673
+
674
+ **No `gesture` props on showcase slides.** Swipe/tap hints belong to the coachmark
675
+ engine, delivered just-in-time when the user is actually on the screen (the tour
676
+ already ships the two-finger glyph). A slide in the value bridge is a still: copy
677
+ plus an `image` (strongly recommended; a slide without one falls back to a
678
+ transparent placeholder).
679
+
680
+ Pre-onboarding placement (the deck _before_ `WireOnboarding`) is still fully
681
+ supported. Placement is host-controlled and the helper is additive; it's just no
682
+ longer the recommended shape, because a generic pre-onboarding intro contradicts
683
+ the "personalize from context" thesis. The once-gate
684
+ (`wire_showcase_<id>_seen`) works **identically** in either placement.
685
+
686
+ It reads the active `OnboardingThemeProvider` theme; pass `theme` to override just
687
+ the showcase, or `storage` / `isTesting` to gate independently of the provider.
688
+
689
+ **v2 server seam (future, not built).** The onboarding server's final turn will
690
+ later return an ordered `showcase: string[]` slide-key list chosen server-side from
691
+ the same answers; the host passes it straight in as the `selection` argument.
692
+ Same catalog, same contract, AI is a drop-in. The field is additive, so kits that
693
+ don't read it simply ignore it (backward-safe).
694
+
695
+ ## In-app reviews (sentiment gate)
696
+
697
+ `wireai-onboarding/reviews` is the drop-in in-app review flow. It asks one neutral
698
+ question, then splits honestly on the rating:
699
+
700
+ - **5 stars** route to the native store review. It calls the **optional** peer
701
+ `expo-store-review` (`StoreReview.requestReview()` when `isAvailableAsync`), and falls
702
+ back to opening a configurable store URL (`{ iosAppId, androidPackage, storeUrl }`) when
703
+ the peer is absent. Fires `store_review_requested`.
704
+ - **1 to 4 stars** open a feather-light feedback form (one free-text line plus an optional
705
+ contact). The text is POSTed to `POST /v1/reviews`. The gate always ends on a warm
706
+ thank-you, never a dead end.
707
+
708
+ By default the prompt presents as a **centered popup**: a card in the middle of the screen
709
+ over a dimmed backdrop, with a spring/fade entrance (RN's built-in `Animated` on the native
710
+ driver, no reanimated). A backdrop tap (or Android back) dismisses it, counting as the same
711
+ resolve path `onResolved` already tracks. Pass `presentation="inline"` for the legacy bare
712
+ card that renders wherever you mount it. Everything is theme-aware, and `presentation="modal"`
713
+ is the default so no prop change is needed to adopt it.
714
+
715
+ Analytics are callback-based, the same convention as the coachmarks. Wire `onEvent` to your
716
+ own analytics; the four moments are `review_prompt_shown`, `review_rating_selected` (carries
717
+ `stars`), `store_review_requested`, and `review_feedback_submitted`. No PII rides in events.
718
+ Feedback text goes only in the POST body.
719
+
720
+ ```tsx
721
+ import { ReviewGate, useReviewGate } from "wireai-onboarding/reviews";
722
+
723
+ // The gate owns WHEN to show. v1 uses the local rules below; pass a server `decision`
724
+ // object and it overrides them (the AI seam, see below).
725
+ const gate = useReviewGate({
726
+ config: { id: "post_onboarding", minSessions: 2, cooldownDays: 90, appVersion: "1.4.0" },
727
+ events, // your tracked app-event count, for the minEvents rule
728
+ });
729
+
730
+ return gate.visible ? (
731
+ <ReviewGate
732
+ appName="Driveline Fit"
733
+ store={{ iosAppId: "123456789", androidPackage: "com.driveline.fit" }}
734
+ target={{ serverUrl: SERVER_URL, apiKey: TENANT_KEY }}
735
+ sessionId={sessionId}
736
+ onEvent={(e) => analytics.track(e.name, { id: e.id, stars: e.stars })}
737
+ onShown={gate.markShown}
738
+ onResolved={gate.markResolved}
739
+ />
740
+ ) : null;
741
+ ```
742
+
743
+ ### The decision seam (server-controlled firing, AI later)
744
+
745
+ `useReviewGate` decides locally today, but the decision is a seam, the same idea as
746
+ `selectTourSteps`. Pass a `decision` object (`{ fire, reason }`) and it overrides the local
747
+ rules. The Wire server already computes exactly this at `GET /v1/reviews/decision`, evaluating
748
+ per-app trigger rules against the app's event stream, and later a learnings-driven evaluator
749
+ replaces the internals behind the same contract. Building the seam now is the whole cost of
750
+ being AI-ready.
751
+
752
+ The firing chain, in order: a server `decision` wins immediately, then after
753
+ `timeoutFallbackMs` the local rules take over (the timeout is a fallback only), and the
754
+ once-per-version gate (`wire_review_<id>_seen`) always applies locally so a server bug can
755
+ never spam the prompt. QA replay reuses the coachmark testing flag: `isTestingCoachmark`
756
+ force-shows the gate.
757
+
758
+ ### Report generic app events (what the triggers evaluate on)
759
+
760
+ `reportAppEvent(target, "content_share", { sessionId, deviceKey })` reports an arbitrary
761
+ in-app event through the existing events transport. The server stores it in the `app.`
762
+ namespace and the review triggers evaluate on it. Keep the name a short stable id and any
763
+ `meta` small and non-PII. This is the seam that lets Wire sit on top of the app's event
764
+ stream over time.
765
+
766
+ ### Store policy (read before you ship)
767
+
768
+ Apple's `requestReview` is quota'd (about 3 per year) and is never guaranteed to show, so
769
+ call it only on the honest 5-star path, never from a button labeled "rate us 5 stars". The
770
+ sentiment question itself stays neutral ("How's your experience?") with honest options.
771
+ Android's in-app review has similar quotas. The kit follows all of this by construction: the
772
+ 5-star tap is the only path that reaches `requestStoreReview`.
773
+
774
+ ### What is next
775
+
776
+ The next retention module reuses this exact server-directed shape:
777
+ `GET /v1/announcements/decision` returns which announcement to show per user or session
778
+ (AI-targeted later, for example announcing the fix for a feature a user complained about),
779
+ with the client once-gating per announcement id through the same storage. Same seam, no code
780
+ yet.
781
+
782
+ ## Feature controls (per-module kill switches)
783
+
784
+ Every activation surface has a switch you can flip from the dashboard "in case of something":
785
+ coachmarks, showcase, onboarding mode, in-app review, and announcements (reserved). The kit
786
+ reads them from `GET /v1/features` (tenant key, `Cache-Control: private, max-age=300`) and gates
787
+ the three client surfaces accordingly. The server owns the onboarding branch (see "Onboarding
788
+ mode" below), so the kit needs no gating there.
789
+
790
+ **The fail-open contract (the whole point).** The kit fetches the flags once per session and
791
+ caches the last-known value through your `storage`. If the endpoint is unreachable, returns 401,
792
+ returns a 5xx, times out, or sends malformed JSON, the kit reuses its last cached value, and with
793
+ no cache it falls back to the all-on defaults (every module on, onboarding `ai`). A control-plane
794
+ outage can only ever be more permissive than the dashboard intends. **It can never dark your app.**
795
+ No failure path ever throws into the host.
796
+
797
+ ### Zero setup is a valid setup
798
+
799
+ Every gated surface works with no flags at all. Omit the wiring and each surface stays on
800
+ (defaults), so a host that never adopts flags sees zero behavior change and makes zero extra
801
+ network calls.
802
+
803
+ ### One fetch for the whole tree (optional)
804
+
805
+ Mount `WireFeaturesProvider` once near your root and every gated surface below it reads the same
806
+ resolved flags from context (one fetch serves all modules):
807
+
808
+ ```tsx
809
+ import { WireFeaturesProvider } from "wireai-onboarding";
810
+
811
+ <WireFeaturesProvider config={{ serverUrl, apiKey, appId, storage }}>
812
+ <App />
813
+ </WireFeaturesProvider>;
814
+ ```
815
+
816
+ Resolution order at each surface: an explicit `features` prop, then the provider context, then a
817
+ lazy fetch from a `featuresConfig` (serverUrl + apiKey) you pass to that surface, then the all-on
818
+ defaults. So you can wire flags globally (provider), per surface (`featuresConfig`), or by handing
819
+ already-resolved flags straight in (`features`). When you consume the built npm subpaths as
820
+ separate bundles, prefer the explicit `features`/`featuresConfig` props (React context is shared
821
+ across subpaths in the source-consumption path, not necessarily across independently-bundled
822
+ entries).
823
+
824
+ ### Per-surface semantics
825
+
826
+ - **Coachmarks** (`CoachmarkProvider`): `coachmarks.enabled === false` makes every tour and
827
+ overlay `show()` request a silent no-op. Nothing paints, no error is thrown, and the
828
+ once-per-tour "seen" gates are **not** consumed, so re-enabling replays the tours correctly.
829
+ - **Showcase** (`FeatureShowcase`): `showcase.enabled === false` renders nothing and calls
830
+ `onDone()` from an effect, exactly as if the showcase had completed. This is the semantics
831
+ chosen deliberately so a disabled showcase can never strand a navigation flow that waits on a
832
+ screen which will not appear. It does not write the "seen" gate, so re-enabling replays it.
833
+ - **Reviews** (`useReviewGate` / `ReviewGate`): `review.enabled === false` means the gate never
834
+ fires (`visible` stays false), whatever the local rules or a server decision say. It composes as
835
+ an extra master switch on top of the existing decision seam.
836
+
837
+ ```tsx
838
+ // Any surface can also take a config directly (lazy fetch), no provider required:
839
+ <FeatureShowcase config={showcase} onDone={next} featuresConfig={{ serverUrl, apiKey, appId, storage }} />
840
+
841
+ const gate = useReviewGate({ config: { id: "post_task" }, features }); // or pass resolved flags
842
+ ```
843
+
844
+ ### Onboarding mode (server-owned, no kit gating)
845
+
846
+ `onboarding.mode` is `ai` | `static` | `off`, and the server owns all three over the same A2A
847
+ contract, so the kit needs nothing for it:
848
+
849
+ - `ai`: the LLM-generated adaptive flow (default).
850
+ - `static`: the server serves a predefined scripted flow with zero LLM calls, over the same card
851
+ contract the kit already parses. The kit renders it exactly like the AI flow.
852
+ - `off`: the server returns a terminal StatusCard on the first turn, which the kit already reads
853
+ as completion (the user drops into the app in one tap). This folds in the legacy per-tenant
854
+ `onboarding_enabled` kill switch.
855
+
856
+ You can still read `onboarding.mode` from the resolved flags if you want to branch host UI, but no
857
+ gating is required in the kit.
858
+
859
+ ## Agent tooling — MCP server + the `wire-ai` skill
860
+
861
+ Two extras in this repo help a coding agent (Claude Code, Cursor, Codex) integrate and
862
+ then *improve* Wire AI, not just eyeball it.
863
+
864
+ ### MCP server (`mcp/`)
865
+
866
+ A local Model Context Protocol server (`@wireai/mcp`, stdio, TypeScript). It is a
867
+ repo-local dev tool — **not** published to npm (excluded from the package `files`
868
+ allowlist). Build it in place:
869
+
870
+ ```bash
871
+ cd mcp && npm install && npm run build
872
+ ```
873
+
874
+ Then add it to your agent client's MCP config:
875
+
876
+ ```json
877
+ {
878
+ "mcpServers": {
879
+ "wireai": {
880
+ "command": "node",
881
+ "args": ["<abs-path>/wireai-onboarding/mcp/dist/index.js"],
882
+ "env": {
883
+ "WIREAI_SERVER_URL": "https://<your-backend>.fly.dev",
884
+ "WIREAI_API_KEY": "wai_<tenant-key>",
885
+ "WIREAI_ADMIN_KEY": "wai_admin_<operator-key>"
886
+ }
887
+ }
888
+ }
889
+ }
890
+ ```
891
+
892
+ Tools (P1 is read-biased; the only writes are the existing register/update):
893
+
894
+ | Tool | Auth key | Does |
895
+ |---|---|---|
896
+ | `wireai_get_integration_guide` | none | the current integration recipe |
897
+ | `wireai_list_components` | none | the card vocabulary |
898
+ | `wireai_get_insights` | tenant `WIREAI_API_KEY` | **read** the funnel brief (`/v1/insights.md`), app resolved from the key; a valid "no data yet" brief when empty |
899
+ | `wireai_get_onboarding_config` | admin `WIREAI_ADMIN_KEY` | **read** the live generation config (context, first question, allowed components) |
900
+ | `wireai_register_onboarding_app` | admin `WIREAI_ADMIN_KEY` | create an app + get its key |
901
+ | `wireai_list_onboarding_apps` | admin `WIREAI_ADMIN_KEY` | list apps |
902
+ | `wireai_get_onboarding_app` | admin `WIREAI_ADMIN_KEY` | one app's raw record |
903
+ | `wireai_update_onboarding_app` | admin `WIREAI_ADMIN_KEY` | patch config, no release |
904
+ | `wireai_get_onboarding_analytics` | admin `WIREAI_ADMIN_KEY` | funnel JSON |
905
+ | `wireai_list_reviews` | admin `WIREAI_ADMIN_KEY` | **read** in-app reviews across tenants (stars, feedback, contact) |
906
+
907
+ Least privilege: for the read-only loop, hand the agent just `WIREAI_SERVER_URL` +
908
+ `WIREAI_API_KEY` (tenant) and skip the admin key. Full detail: [`mcp/README.md`](./mcp/README.md).
909
+
910
+ ### `wire-ai` skill (`.claude/skills/wire-ai`)
911
+
912
+ A pure-Markdown Claude skill for *using* Wire AI easily: install the kit + wire the
913
+ `WireOnboarding` provider, read your insights (via the MCP or `/v1/insights.md`), and run
914
+ the improve loop (read insights, then adjust the app description / first question /
915
+ generation context). It also carries the `BOILERPLATE20` credit note. For the full
916
+ scaffold-the-screen integration job, the [`wire-rn-integration`](./INTEGRATION_PROMPT.md)
917
+ skill drives it end to end.
918
+
919
+ ## More from Code Meet AI
920
+
921
+ **Open source:** [wireai-rn](https://github.com/chohra-med/wireai-rn) · [expo_boilerplate](https://github.com/chohra-med/expo_boilerplate) · [colorway-c-brand](https://github.com/chohra-med/colorway-c-brand) · [claude_design_skill](https://github.com/chohra-med/claude_design_skill)
922
+ **Products:** [AI Mobile Launcher](https://aimobilelauncher.com) · [AI Web Launcher](https://aiweblauncher.com) · [Wire AI](https://getwireai.com) · [CasaInnov](https://casainnov.com)
923
+ **Follow:** [Newsletter](https://codemeetai.substack.com) · [YouTube](https://youtube.com/@codemeetai)