@wireai/activation 0.14.3 → 0.16.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 (85) hide show
  1. package/AGENTS.md +96 -21
  2. package/CHANGELOG.md +780 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +110 -31
  5. package/dist/analytics/index.d.mts +35 -13
  6. package/dist/analytics/index.d.ts +35 -13
  7. package/dist/analytics/index.js +288 -127
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +288 -127
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +15 -1
  12. package/dist/coachmarks/index.d.ts +15 -1
  13. package/dist/coachmarks/index.js +120 -22
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +120 -22
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
  18. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
  19. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  20. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.js +125 -36
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +125 -36
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/questionnaire/index.d.mts +3 -16
  28. package/dist/questionnaire/index.d.ts +3 -16
  29. package/dist/questionnaire/index.js +202 -46
  30. package/dist/questionnaire/index.js.map +1 -1
  31. package/dist/questionnaire/index.mjs +203 -47
  32. package/dist/questionnaire/index.mjs.map +1 -1
  33. package/dist/reviews/index.d.mts +10 -6
  34. package/dist/reviews/index.d.ts +10 -6
  35. package/dist/reviews/index.js +269 -52
  36. package/dist/reviews/index.js.map +1 -1
  37. package/dist/reviews/index.mjs +270 -53
  38. package/dist/reviews/index.mjs.map +1 -1
  39. package/dist/showcase/index.d.mts +1 -1
  40. package/dist/showcase/index.d.ts +1 -1
  41. package/dist/showcase/index.js +106 -20
  42. package/dist/showcase/index.js.map +1 -1
  43. package/dist/showcase/index.mjs +107 -21
  44. package/dist/showcase/index.mjs.map +1 -1
  45. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  46. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  47. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  48. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  49. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  50. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  51. package/llms.txt +9 -9
  52. package/package.json +6 -9
  53. package/src/WireOnboarding.tsx +4 -1
  54. package/src/analytics/currentSession.ts +141 -4
  55. package/src/analytics/index.ts +6 -1
  56. package/src/analytics/reportClientEvent.ts +19 -10
  57. package/src/analytics/useAnalytics.ts +74 -15
  58. package/src/analytics/wireDoctor.ts +152 -7
  59. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  60. package/src/coachmarks/runtime.ts +156 -2
  61. package/src/coachmarks/types.ts +7 -2
  62. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  63. package/src/context/deviceId.ts +72 -6
  64. package/src/features/WireFeaturesProvider.tsx +72 -12
  65. package/src/features/fetchWireFeatures.ts +49 -11
  66. package/src/features/useWireFeatures.ts +39 -3
  67. package/src/identity/identityRecord.ts +15 -2
  68. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  69. package/src/questionnaire/transport.ts +22 -8
  70. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  71. package/src/reviews/ReviewGate.tsx +125 -37
  72. package/src/reviews/decision.ts +11 -1
  73. package/src/reviews/idempotency.ts +109 -0
  74. package/src/reviews/index.ts +4 -2
  75. package/src/reviews/runtime.ts +44 -13
  76. package/src/reviews/transport.ts +39 -20
  77. package/src/reviews/types.ts +7 -0
  78. package/src/reviews/useReviewGate.ts +57 -7
  79. package/src/session-analytics/lifecycle.ts +16 -0
  80. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  81. package/src/session-analytics/useSessionStart.ts +22 -2
  82. package/src/showcase/FeatureShowcase.tsx +50 -3
  83. package/src/types.ts +15 -8
  84. package/src/utils/submitResult.ts +17 -9
  85. package/src/utils/withDeadline.ts +70 -0
@@ -3,12 +3,9 @@
3
3
  Copy everything in the block below and paste it into Claude Code (or any coding agent) **from the
4
4
  root of your React Native / Expo app**. Fill in the two values at the top first.
5
5
 
6
- > Fastest path instead of this prompt: run the **`wire-rn-integration`** Claude skill, the same job
7
- > fully automated. Use this prompt when you don't have that skill installed.
8
-
9
6
  ---
10
7
 
11
- ```
8
+ ```text
12
9
  You are integrating the `@wireai/activation` package into THIS React Native / Expo app, a drop-in
13
10
  AI onboarding flow. Do it end to end, match my app's existing conventions, and leave it
14
11
  type-checking green. Read the package's README.md, llms.txt, and AGENTS.md (in node_modules/@wireai/activation
@@ -16,14 +13,20 @@ after install) before writing code.
16
13
 
17
14
  MY VALUES:
18
15
  - WIREAI_SERVER_URL: <paste the backend URL, e.g. https://...fly.dev>
19
- - WIREAI_API_KEY: <paste the wai_… app key from the getwireai console / register_<app>.py>
16
+ - WIREAI_API_KEY: <paste the wai_… app key from your app's Integrate tab in the console:
17
+ sign up at https://getwireai.com/signup (app name + email, then
18
+ confirm by email and set a password: that step is what creates the app
19
+ and its key), then copy the key from
20
+ https://getwireai.com/console/<app-id>/integrate. The server URL is the
21
+ serverUrl line of the init snippet on that same tab, NOT the "Server
22
+ URL" in that tab's Add MCP dialog, which is a different host>
20
23
  - APP_ID: <paste my app id, e.g. my-app>
21
24
 
22
25
  STEPS (do them in order, stop and ask if a convention is ambiguous):
23
26
  1. Detect my conventions: navigation lib + where the signup/first-run flow lives, my env-var
24
27
  typing, my profile-update / persistence path, my theme tokens (colors/fonts), my path aliases.
25
- 2. Install: `@wireai/activation` and `wireai-rn` (react + react-native are already peers). Use my
26
- package manager (yarn/npm/pnpm, detect it).
28
+ 2. Install: `npm install @wireai/activation wireai-rn zod react-native-safe-area-context` (react +
29
+ react-native are already peers). Use my package manager (yarn/npm/pnpm, detect it).
27
30
  3. Metro: set `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from
28
31
  `@wireai/activation/metro` in metro.config.js (preserve my existing config). This pins one copy
29
32
  of react/react-native/wireai-rn/zod; do NOT skip it.
@@ -31,9 +34,11 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
31
34
  to my env files and my env typing.
32
35
  5. Theme: build a theme from my brand with `themeFromBrand({ primary: "<my brand color>" })` or a
33
36
  Partial<OnboardingTheme>; match my dark/light.
34
- 6. Screen: scaffold an onboarding screen that renders
35
- `<WireOnboarding config={wireConfigFromEnv({ appId: APP_ID })} theme={...} illustrations={...}
36
- onComplete={persist} fallbackFlow={<MyExistingStaticOnboarding/>} onEvent={logEvent} />`.
37
+ 6. Screen: scaffold an onboarding screen. Build the config ONCE and gate on it first, because
38
+ `wireConfigFromEnv` returns `null` when the env is unset while `config` is a required non-null
39
+ prop: `const config = wireConfigFromEnv({ appId: APP_ID }); if (!config) return <MyExistingStaticOnboarding/>;`
40
+ Then render
41
+ `<WireOnboarding config={config} theme={...} illustrations={...} onComplete={persist} fallbackFlow={<MyExistingStaticOnboarding/>} onEvent={logEvent} />`.
37
42
  - Gate entry with `isOnboardingEnabled({ remote })` (wire my remote-config boolean if I have one).
38
43
  - `persist`: write ALL of `result.answers` (use `deriveAnswers(result.raw)` to fill gaps) through
39
44
  my normal profile-update path. Do NOT re-ask anything a dedicated screen already owns.
@@ -48,9 +53,17 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
48
53
  - Analytics: log via `toAnalyticsEvent(e)` → my logger, using the canonical `wire_onboarding_*` names.
49
54
  7. Navigation: mount the screen in my first-run/signup flow; branch to the static flow when the gate
50
55
  is off or config is missing.
51
- 8. Lifecycle (do NOT skip): mount `useLifecycleEvents(config, { deviceKey, sessionCount, userId })`
52
- ONCE at my app root, above the navigator, before anything else touches analytics. Pass
53
- `config.storage`. It is the only path that emits `app.first_open`, it registers the per-open
56
+ 8. Lifecycle (do NOT skip): mount
57
+ `useLifecycleEvents({ serverUrl, apiKey, appId, storage }, { deviceKey, sessionCount, userId })`
58
+ ONCE at my app root, above the navigator, before anything else touches analytics.
59
+ `sessionCount` is MINE to own: a counter I persist and increment once per app open, starting at
60
+ 1 on the very first launch. Hardcoding `1` compiles, ships, and pins every user at `returning:
61
+ false` forever with nothing reporting an error. If I have no such counter yet, create one
62
+ (read, increment, write) in the same storage I pass above, or say so instead of inventing a
63
+ constant. That first
64
+ argument is a `LifecycleConfig` built from explicit fields, NOT the config from step 6:
65
+ `wireConfigFromEnv` can return `null`, and `storage` is not a field of `WireOnboardingConfig`.
66
+ It is the only path that emits `app.first_open`, it registers the per-open
54
67
  session id every other surface correlates to, and with storage it stamps the kit's persisted auto
55
68
  `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. If my
56
69
  storage adapter throws or rejects, the kit treats it as no storage and stamps no auto key at all
@@ -62,11 +75,26 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
62
75
  `journal_done`. On my home/gate screen call
63
76
  `const { track, sessionId, revalidation } = useWireActivation({ serverUrl: WIREAI_SERVER_URL, apiKey: WIREAI_API_KEY, appId: APP_ID, deviceKey })`
64
77
  from `@wireai/activation`, fetch the gate decision in a `useEffect` keyed on `[revalidation]`
65
- (`fetchReviewDecision({ serverUrl, apiKey }, { sessionId, deviceKey })` set it into
66
- `useReviewGate({ config, decision })`; questionnaires use `fetchQuestionnaireDecision` +
67
- `useQuestionnaireGate` the same way), and at the action site call `await track("journal_done")`.
68
- Do NOT hand-roll the session id or the POST; `wire.track` owns both and revalidates the gate on
69
- a successful 2xx.
78
+ (`fetchReviewDecision({ serverUrl, apiKey }, { sessionId, deviceKey })`), and at the action site
79
+ call `await track("journal_done")`. Do NOT hand-roll the session id or the POST; `wire.track`
80
+ owns both and revalidates the gate on a successful 2xx. Questionnaires use
81
+ `fetchQuestionnaireDecision` + `useQuestionnaireGate` the same way, with the same three rules:
82
+ - THE GATE NEEDS SYNCHRONOUS STORAGE. Pass `storage` to the hook (or mount `CoachmarkProvider`
83
+ with one above it) as a `CoachmarkStorage`, whose `getItem(key)` returns `string | null` and
84
+ NOT a Promise. This is a different shape from `<WireOnboarding storage>`, which takes the
85
+ AsyncStorage-style adapter. Without it the app-open counter is pinned at 1 forever, the
86
+ fail-closed `minSessions` default of 2 can never be met, and the gate silently never fires;
87
+ an ASYNC adapter is detected and substituted with a fail-closed store, same outcome. Use my
88
+ app's existing synchronous store (MMKV or similar). If I have none, say so.
89
+ - THE HOOK'S `config` IS THE GATE'S CONFIG, not the kit transport config from step 6: it needs
90
+ an `id`. Write `useReviewGate({ config: { id: "post_win" }, decision: served ?? undefined, storage: gateStorage })`.
91
+ The `?? undefined` is not optional either: the fetch resolves `ReviewDecisionResponse | null`
92
+ and the option is `decision?: ReviewDecision`, so the raw result does not type-check.
93
+ - WIRE THE COMPONENT, not just the hook. `<ReviewGate>` has no `gate` prop. Render it when
94
+ `gate.visible` and pass `onShown={gate.markShown}` and `onResolved={gate.markResolved}`.
95
+ Those two write the cooldown timestamp and the once-per-version seen gate; leave them out and
96
+ it compiles, shows, and re-prompts the same user on every open. The full wiring example is the
97
+ "Wiring `<ReviewGate>`" block in AGENTS.md.
70
98
  10. RevenueCat (only if my app sells subscriptions through `react-native-purchases`): wire my paywall
71
99
  to the same event stream with `createRevenueCatBridge({ analytics, entitlementId: "<my
72
100
  entitlement, e.g. pro>" })` from `@wireai/activation`. Do NOT add or import
@@ -75,17 +103,27 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
75
103
  hand-rolled paywall analytics calls with `paywallShown` / `checkoutStarted` / `purchaseCompleted`
76
104
  / `purchaseFailed` / `purchasesRestored`, and call `syncPlanTier(customerInfo)` once at launch.
77
105
  THE JOIN KEY: pass the SAME device key to the analytics instance AND to onboarding via
78
- `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`, because purchases join to
106
+ `<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`, because purchases join to
79
107
  onboarding on `user_context.device_key` and never on `session_id` (those are separate id spaces
80
108
  and intersecting them returns zero rows). If my app has no single stable device key yet, say so
81
109
  instead of inventing one.
82
110
  11. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths. Then
83
111
  prove the events wiring actually works, because `/v1/events` answers 200 for a batch it discards:
84
112
  behind an `if (__DEV__)` guard, call
85
- `wireDoctor({ target: { serverUrl: WIREAI_SERVER_URL, apiKey: WIREAI_API_KEY }, storage })` from
86
- `@wireai/activation/analytics` once at app root and log the report. Every check must come back
87
- `ok`. A failing `round_trip` names the field the server refused. Remove the call, or leave it
88
- dev-gated, once the report is green.
113
+ `wireDoctor({ target: { serverUrl: WIREAI_SERVER_URL, apiKey: WIREAI_API_KEY }, storage, join: {
114
+ appId: WIREAI_APP_ID, userContext } })` from `@wireai/activation/analytics` once at app root and
115
+ log the report. Every check must come back `ok`. A failing `round_trip` names the field the
116
+ server refused. Remove the call, or leave it dev-gated, once the report is green.
117
+ PASS `join`. Without it the `join_key` check reports NOT EVALUATED and fails the report on
118
+ purpose: a report that never looked at the join has not earned a green.
119
+ READ THE SCOPE OF THAT GREEN. `wireDoctor` runs five checks: `target` (the shape of the url +
120
+ key), `reachability`, `storage` (writability), `round_trip` (one probe event the server
121
+ acknowledges as written), and `join_key` (this session will carry a `user_context.device_key`,
122
+ yours or the kit's auto-injected one, step 7). It still does NOT check that the lifecycle hook
123
+ is mounted, and it does NOT check `min_sessions`. So an integration that skipped step 8's
124
+ lifecycle mount passes every check green while its `activated` funnel stays permanently at zero.
125
+ A green doctor means "events reach the server and can be joined"; it does not mean "the funnel
126
+ works". Verify step 8 by reading the code, not by reading the report.
89
127
 
90
128
  Report back: the files you changed (path:line), the typecheck result, and anything you couldn't
91
129
  infer about my conventions.
package/README.md CHANGED
@@ -41,20 +41,53 @@ library? Pass your own nodes through the `icons` prop.
41
41
 
42
42
  ## How to include it in an app (end-to-end)
43
43
 
44
- > Fastest path: run the **`wire-rn-integration`** Claude skill — it does all of the
45
- > steps below automatically (detects your conventions, wires Metro, derives a theme,
46
- > scaffolds the screen, type-checks). The manual steps, in order:
44
+ > Fastest path: paste [`INTEGRATION_PROMPT.md`](./INTEGRATION_PROMPT.md) into your coding
45
+ > agent it ships in this package and does all of the steps below automatically (detects
46
+ > your conventions, wires Metro, derives a theme, scaffolds the screen, type-checks). The
47
+ > manual steps, in order:
47
48
 
48
- **0. Register the app on the backend get a tenant key.** The kit talks to the
49
+ **0. Get your `apiKey` and `serverUrl` from the console.** The kit talks to the
49
50
  multi-tenant backend over A2A; the **`apiKey` is per-app and resolves the tenant
50
- server-side**. Create the app in the getwireai **console** (or run the backend's
51
- `register_<app>.py`) it mints a `wai_…` key. Note the **server URL** (e.g. the fly
52
- dev/prod URL). Nothing renders without these two.
51
+ server-side**. Nothing renders without both values. Two stops in the browser with an
52
+ email round trip between them:
53
+
54
+ 1. **Create an account** → **<https://getwireai.com/signup>**. You give your app's name
55
+ and an email; the confirmation link is where you choose a password. That one step
56
+ creates the account, your **first app** and its `wai_…` key, and lands you inside the
57
+ console already signed in.
58
+ 2. **Copy both values** from that app's **Integrate** tab
59
+ (`https://getwireai.com/console/<your-app-id>/integrate`). The **API key** is a copy
60
+ field; the **server URL** is the `serverUrl` line of the ready-made init snippet on
61
+ that same tab. The key stays readable there on every visit, so there is nothing to
62
+ catch as it goes past.
63
+
64
+ > ⚠️ **Two different things are called "Server URL" in that console.** The one you want is
65
+ > the `serverUrl` in the init snippet. The **Add MCP** dialog on the same tab also shows a
66
+ > copy field labelled "Server URL", and that is the **MCP endpoint on a different host** —
67
+ > it is for pointing an AI client at your account, and it is *not* a valid
68
+ > `EXPO_PUBLIC_WIREAI_SERVER_URL`.
69
+
70
+ Later: sign back in at **<https://getwireai.com/login>**, and register a second app at
71
+ **<https://getwireai.com/console/register>** if you need one — signup already made the
72
+ first.
73
+
74
+ > **Keys are per environment.** A key's label *is* its environment (`dev` / `prod` /
75
+ > `staging` / custom), so use a different key in dev than in production and your analytics
76
+ > split by that label. The **Keys and environments** section of the same **Integrate** tab
77
+ > lists your keys and can **revoke** one. Issuing a new key is **not self-serve yet**, so
78
+ > ask for an extra environment via the console's **Support** section.
79
+
80
+ > ⚠️ **The `apiKey` is a tenant key, not a login.** It resolves your app server-side and is
81
+ > *expected to ship inside your app bundle* — the documented path below puts it in
82
+ > `EXPO_PUBLIC_WIREAI_API_KEY`, which Metro inlines at build time. It cannot authenticate a
83
+ > console session; an app key presented on a console route is refused. Do still read it from
84
+ > an env var rather than a committed literal, so a build can point at a different
85
+ > environment's key without a code change.
53
86
 
54
87
  **1. Install the package** (plus its peers):
55
88
 
56
89
  ```bash
57
- npm install @wireai/activation wireai-rn
90
+ npm install @wireai/activation wireai-rn zod react-native-safe-area-context
58
91
  # react + react-native are peers (already in any RN/Expo app)
59
92
  ```
60
93
 
@@ -74,7 +107,7 @@ module.exports = withWireOnboarding(getDefaultConfig(__dirname));
74
107
 
75
108
  **3. Config from env** (so you can flip backends without a code change):
76
109
 
77
- ```
110
+ ```env
78
111
  EXPO_PUBLIC_WIREAI_API_KEY=wai_…
79
112
  EXPO_PUBLIC_WIREAI_SERVER_URL=https://…
80
113
  EXPO_PUBLIC_WIREAI_APP_ID=my-app
@@ -107,6 +140,8 @@ just navigate onward:
107
140
  theme={theme}
108
141
  illustrations={{ ...defaultIllustrations, ...myIllustrations }}
109
142
  validators={{ username: checkUsernameAvailable }}
143
+ storage={storage}
144
+ fallbackFlow={<MyExistingStaticOnboarding />}
110
145
  onComplete={(r) => { persist(r.answers); goNext(); }}
111
146
  onSkip={goNext}
112
147
  onError={fallBackToStaticOnboarding}
@@ -138,11 +173,11 @@ Install the package and wire Metro with the one-call helper:
138
173
 
139
174
  ```bash
140
175
  # from npm (public)
141
- npm install @wireai/activation
142
- # …or from a tarball (see Distribution below)
143
- npm install ./@wireai/activation-0.1.3.tgz
144
- # …or over SSH
145
- npm install git+ssh://git@github.com/<org>/@wireai/activation.git
176
+ npm install @wireai/activation wireai-rn zod react-native-safe-area-context
177
+ # …or, for a pre-publish or pinned build only (see Distribution below):
178
+ # `x.y.z` is a placeholder — use the version `npm run pack:tarball` actually produced.
179
+ npm install ./wireai-activation-x.y.z.tgz
180
+ npm install git+ssh://git@github.com/<org>/<repo>.git
146
181
  ```
147
182
 
148
183
  ```js
@@ -154,7 +189,9 @@ module.exports = withWireOnboarding(getDefaultConfig(__dirname));
154
189
  ```
155
190
 
156
191
  ```tsx
157
- // 3-line minimal usage themed from a brand color, config from env, default art
192
+ // Minimal usage. "Minimal" still carries `fallbackFlow` and a join key, because omitting
193
+ // either fails SILENTLY: no fallback dead-ends the user on a backend error, and no join key
194
+ // leaves the `activated` funnel reading zero forever with no error anywhere.
158
195
  import { WireOnboarding, themeFromBrand, wireConfigFromEnv, defaultIllustrations } from "@wireai/activation";
159
196
 
160
197
  export function Onboarding() {
@@ -165,7 +202,10 @@ export function Onboarding() {
165
202
  config={config}
166
203
  theme={themeFromBrand({ primary: "#15B0B0" })}
167
204
  illustrations={defaultIllustrations}
205
+ storage={storage}
206
+ fallbackFlow={<MyExistingStaticOnboarding />}
168
207
  onComplete={(r) => persist(r.answers)}
208
+ onSkip={goNext}
169
209
  />
170
210
  );
171
211
  }
@@ -204,21 +244,23 @@ returns the same config. `options.appRoot` defaults to `process.cwd()`.
204
244
  ## Distribution
205
245
 
206
246
  `npm run pack:tarball` runs the build (via `prepack`) and produces
207
- `@wireai/activation-x.y.z.tgz` containing `dist` + `src` + `metro` + README +
208
- CHANGELOG. The primary channel is **npm** (`npm install @wireai/activation`); a
209
- tarball (`npm install ./@wireai/activation-x.y.z.tgz`) or a `git+ssh://…` install
247
+ `wireai-activation-x.y.z.tgz` containing `dist` + `src` + `metro` + README +
248
+ CHANGELOG. The primary channel is **npm** (the install line above); a
249
+ tarball (`npm install ./wireai-activation-x.y.z.tgz`) or a `git+ssh://…` install
210
250
  work too for pre-publish or pinned builds.
211
251
 
212
252
  ## Quick start
213
253
 
214
254
  ```tsx
215
- import { WireOnboarding } from "@wireai/activation";
255
+ import { WireOnboarding, activationJoinContext } from "@wireai/activation";
216
256
 
217
257
  <WireOnboarding
218
258
  config={{ apiKey, serverUrl, appId: "my-app" }}
219
259
  theme={myBrandTheme}
220
260
  illustrations={{ momentum: <MyMomentumSvg /> }}
221
261
  validators={{ username: checkUsernameAvailable }}
262
+ userContext={activationJoinContext(deviceKey)}
263
+ fallbackFlow={<MyExistingStaticOnboarding />}
222
264
  onComplete={(result) => persist(result.answers)}
223
265
  onSkip={skip}
224
266
  onError={() => fallBackToStaticOnboarding()}
@@ -252,7 +294,7 @@ import { WireOnboarding } from "@wireai/activation";
252
294
  | `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. |
253
295
  | `retainSessionOnComplete` | `boolean` | Opt IN: keep the persisted session seed alive across completion. A multi-stage signup that re-enters onboarding (typically a `key=` remount) then resumes the SAME session instead of minting a fresh `metadata.sessionId`, which the backend adopts as a second `contextId` and double-counts as a `session_started`. Omitted or `false` clears the seed on completion, the legacy single-stage behavior, so leaving it unset changes nothing. With `true`, freshness comes from `sessionTtlMs` plus an explicit new-run signal (scope a new `persistKey`, e.g. a per-signup id). With `storage` only. |
254
296
  | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}`. If your app owns no device id, pass a working `storage` and leave this prop alone: the kit injects its own key, and only after it has confirmed the key actually persists. ⛔ Do not hand-build that key with `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`, because `resolveAutoDeviceKey` is synchronous by contract (it hands back a fresh mint and adopts the persisted id a storage read later) and it cannot tell you whether the id survives the launch at all. Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other 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 on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
255
- | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
297
+ | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you actually supplied, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). The kit counts yours as supplied only when it is a string with at least one non-whitespace character, so a non-string or an all-whitespace `device_key` is treated as absent and the auto key is merged over it. |
256
298
  | `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). |
257
299
 
258
300
  ## Helpers (the reusable substrate)
@@ -273,7 +315,7 @@ You rarely hand-roll config, gating, analytics, or attribution — the kit ships
273
315
 
274
316
  > **`onEvent`** fires `started` / `resumed` / `turn` / `error` / `retry` / `fallback` / `permission` (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. Since 0.13.6 the `turn` event also carries `variant`, the experiment arm the backend assigned, from the first card that declares one onwards; that is how you read the arm DURING the flow rather than waiting for `onComplete`. The key is absent when no experiment is running, and the kit never attaches the arm to an analytics event, so arm-segmented funnels are the host's job. 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)).
275
317
 
276
- **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.
318
+ **Integrating with an AI agent?** See [`INTEGRATION_PROMPT.md`](./INTEGRATION_PROMPT.md) (copy-paste prompt for Claude Code) and [`llms.txt`](./llms.txt).
277
319
 
278
320
  ## Device & user context
279
321
 
@@ -331,7 +373,7 @@ setUserId(newUser.id); // the kit fires `identify` and binds the running session
331
373
  ```tsx
332
374
  import { identifyOnboarding } from "@wireai/activation";
333
375
 
334
- const contextIdRef = useRef<string>();
376
+ const contextIdRef = useRef<string | undefined>(undefined);
335
377
 
336
378
  <WireOnboarding
337
379
  config={config}
@@ -378,6 +420,8 @@ await clearUserContext({ storage, appId }); // the wire/activation path (then re
378
420
 
379
421
  **3. The auto `device_key` is a per-install identifier.** When you supply no `deviceKey`, the analytics and activation surfaces auto-mint a stable per-install `device_key`, persist it via `storage` (`wireai:analytics:deviceKey:<appId>`), and stamp it on every event so the server's review/questionnaire gating and A/B stickiness work out of the box. It carries no hardware id, no IDFA/GAID, and cannot be joined across apps (the privacy category of a first-party cookie), but it IS a persistent per-install id. If you adopt these surfaces, declare it in your App Privacy / Data Safety accordingly. Supply your own `deviceKey` to override it.
380
422
 
423
+ **The review gate is on that list too, even if it is the only surface you mount.** Since 0.15.0 `<ReviewGate>` stamps `meta.device_key` onto its `POST /v1/reviews` body when you passed none. That id is the unit the server scopes the review's idempotency key to, and a post without it gets the key discarded. The gate adopts whatever per-install id the analytics and activation surfaces already registered in this process, so it never opens a second id space. On a reviews-only host, where nothing else registered one, it mints its own instead. `<ReviewGate>` takes no `storage` prop, so that minted id is process-scoped: a fresh value on each launch, never persisted, rather than a stable per-install one. Either way it leaves the device inside a review payload. Declare it. Pass your own non-blank `meta.device_key` and it is forwarded verbatim instead.
424
+
381
425
  ### Do not pass `sessionId` to `createAnalytics`
382
426
 
383
427
  `CreateAnalyticsConfig.sessionId` is an opt-out knob, not a default. Set it and **every** event that
@@ -387,6 +431,7 @@ following the live per-open session the kit registers from `app.session_started`
387
431
  collapse onto a single device-scoped id: one "first open" for the life of the install, however many
388
432
  times the user comes back. The kit warns about it in dev builds.
389
433
 
434
+ <!-- doc-check: skip - two ALTERNATIVE call shapes shown side by side; one file cannot declare the same const twice -->
390
435
  ```tsx
391
436
  const analytics = createAnalytics({ serverUrl, apiKey, storage, appId }); // ✅ follows each app-open
392
437
  const analytics = createAnalytics({ serverUrl, apiKey, sessionId: myId }); // ⚠️ frozen for good
@@ -569,6 +614,7 @@ that name (so an app can ship brand SVG/Lottie the kit never imports), and other
569
614
  renders the backend `imageUrl` via RN `<Image>`. Leave a dashboard image's URL empty
570
615
  to mean "the app provides this one by name."
571
616
 
617
+ <!-- doc-check: skip - a single JSX ATTRIBUTE, not a statement; there is no expression form for it -->
572
618
  ```tsx
573
619
  illustrations={{
574
620
  "before-after": <BeforeAfterIllustration width={300} />, // your SVG component
@@ -611,6 +657,7 @@ waiting on every shipped app to update.
611
657
  native rebuild (most Expo apps already ship it). Skip it and the cards render fine without them.
612
658
  Or skip it and bring your own brand nodes, keyed by the same vocabulary names:
613
659
 
660
+ <!-- doc-check: skip - a single JSX ATTRIBUTE, not a statement; there is no expression form for it -->
614
661
  ```tsx
615
662
  icons={{
616
663
  instagram: <BrandInstagram />, // override one name with your own mark
@@ -680,6 +727,7 @@ An `afterCard` the flow never reaches **clamps to `"beforeEnd"`** instead of sil
680
727
 
681
728
  The kit ships English good enough to ship. Override any single line and the rest of the default stays:
682
729
 
730
+ <!-- doc-check: skip - one OBJECT PROPERTY of a permission screen, not a statement -->
683
731
  ```tsx
684
732
  copy: { title: "Never miss a session", primaryLabel: "Turn on reminders" }
685
733
  ```
@@ -705,7 +753,15 @@ The gap between `wire_permission_screen_shown` and `wire_permission_primer_accep
705
753
  ## Backend coupling
706
754
 
707
755
  The backend (`wire-rn/examples/dynamic-onboarding/server`) is the source of truth
708
- for what the agent may emit. To make a new card emittable you must, server-side:
756
+ for what the agent may emit.
757
+
758
+ > ℹ️ **This section is a VENDOR operation, not something you can do from your app.** Both
759
+ > steps below run against the hosted backend and end in a deploy, so if you need a card
760
+ > that is not in the list above, ask via the console's **Support** section rather than
761
+ > trying to perform them. It is documented here because it explains *why* an unknown card
762
+ > is never emitted, which is otherwise a confusing silence.
763
+
764
+ To make a new card emittable you must, server-side:
709
765
 
710
766
  1. add it to `app/wire.py` `KNOWN_COMPONENTS` + `COMPONENT_DOCS`, **and**
711
767
  2. add its name to the tenant's `allowed_components` (the `register_*.py` script),
@@ -743,12 +799,14 @@ useEffect(() => {
743
799
  wireDoctor({
744
800
  target: { serverUrl: DRIVELINE_SERVER_URL, apiKey: DRIVELINE_API_KEY },
745
801
  storage: AsyncStorage,
802
+ // The same values you pass <WireOnboarding>. Omit `join` and the join_key check FAILS.
803
+ join: { appId: DRIVELINE_APP_ID, userContext },
746
804
  }).then((report) => console.log(report.ok, report.checks));
747
805
  }, []);
748
806
  ```
749
807
 
750
808
  It resolves `{ ok, checks: [{ name, ok, detail }] }` and **never throws**, whatever the target, the
751
- storage adapter or the network does. Four independent checks:
809
+ storage adapter or the network does. Five independent checks:
752
810
 
753
811
  | `name` | What it proves |
754
812
  |---|---|
@@ -756,9 +814,23 @@ storage adapter or the network does. Four independent checks:
756
814
  | `reachability` | `GET /v1/events/contract` answers, so the URL really points at a Wire server. |
757
815
  | `storage` | The offline queue can persist, via a write / read / delete probe on its own key. Your pending backlog is never read, overwritten or deleted. |
758
816
  | `round_trip` | One synthetic event survives real server validation. It goes out with `dry_run`, so nothing is written and no metric moves. The verdict is `written === 1 && skipped === 0`, read from the ack body, because the status code cannot tell you. |
817
+ | `join_key` | The events will be **joinable**: this session will carry a `user_context.device_key`, either the one you supplied or the one the kit auto-injects. Needs `join`. |
759
818
 
760
819
  A `ts`-shaped drift shows up as `skipped=1` carrying the server's own `field: ts`.
761
820
 
821
+ **Why `join_key` is its own check.** The first four all answer one question — *does an event leave
822
+ and get accepted?* None of them answers *can it be joined?* `user_context.device_key` is the only
823
+ thing that stitches an onboarding session to everything the app reports later, so an integration
824
+ without one writes every event, passes all four, and still reports a permanent **zero** in the
825
+ `activated` funnel. Two real consumers shipped exactly that.
826
+
827
+ **Omitting `join` fails the check** rather than skipping it, for the same reason a release build
828
+ reports `dev_only` as a failure: a report that never looked at the join has not earned a green.
829
+
830
+ `join_key` is **strictly read-only** — one `getItem`, plus pure predicates. It never mints a key,
831
+ never writes the identity registry and never persists anything, so running the doctor cannot itself
832
+ create the split-identity defect it exists to detect.
833
+
762
834
  **Dev-only by contract.** The `__DEV__` guard is the first statement in the function, so a release
763
835
  build does nothing at all: no network call, no storage write. In that case the report says it was
764
836
  skipped rather than reporting a pass it never earned. A host that never imports `wireDoctor` does not
@@ -818,8 +890,10 @@ below) — nothing to rewire.
818
890
  **1. Mount the provider once, around your `NavigationContainer`.** It renders the
819
891
  overlay host as a root sibling so a ring can paint _above_ the bottom tab bar
820
892
  (which react-navigation draws over screen components). It takes a **synchronous**
821
- storage adapter — gate reads must not be async or a ring flashes before the
822
- "already seen" check resolves. An MMKV wrapper is three lines:
893
+ storage adapter — gate reads resolve during render. An async adapter (e.g.
894
+ AsyncStorage handed straight in) is caught by a probe and the gates FAIL CLOSED:
895
+ coachmarks, showcases and the review/questionnaire gates all stay silent, and a
896
+ dev-build warning names the fix. An MMKV wrapper is three lines:
823
897
 
824
898
  ```tsx
825
899
  import { CoachmarkProvider } from "@wireai/activation/coachmarks";
@@ -857,9 +931,9 @@ storage, so the app keeps only its own domain gates. Steps must be a memoized
857
931
  array; step callbacks are held in a ref (parent re-renders never re-fire a step):
858
932
 
859
933
  ```tsx
860
- import { useCoachmarkTour } from "@wireai/activation/coachmarks";
934
+ import { useCoachmarkTour, type CoachmarkStep } from "@wireai/activation/coachmarks";
861
935
 
862
- const steps = useMemo(
936
+ const steps = useMemo<CoachmarkStep[]>(
863
937
  () => [
864
938
  { id: "feed_scroll", message: "Swipe to explore.", gesture: "swipe_up" },
865
939
  { id: "group_tab", message: "Your groups live here.", anchorId: "group_tab",
@@ -951,6 +1025,7 @@ const catalog = [
951
1025
 
952
1026
  // Sequence: WireOnboarding → onComplete(result) → select from answers → FeatureShowcase → app
953
1027
  <WireOnboarding
1028
+ config={config}
954
1029
  onComplete={(result) => {
955
1030
  persist(result.answers);
956
1031
  setSlides(selectShowcaseSlides(catalog, idsFromAnswers(result.answers)));
@@ -1029,6 +1104,7 @@ own analytics; the four moments are `review_prompt_shown`, `review_rating_select
1029
1104
  `stars`), `store_review_requested`, and `review_feedback_submitted`. No PII rides in events.
1030
1105
  Feedback text goes only in the POST body.
1031
1106
 
1107
+ <!-- doc-check: skip - a COMPONENT BODY fragment: the `return` needs an enclosing component, which the surrounding prose supplies -->
1032
1108
  ```tsx
1033
1109
  import { ReviewGate, useReviewGate } from "@wireai/activation/reviews";
1034
1110
 
@@ -1070,7 +1146,10 @@ import { fetchReviewDecision, useReviewGate } from "@wireai/activation/reviews";
1070
1146
  // so deviceKey is the identity that matters. sessionId is optional.
1071
1147
  const decision = await fetchReviewDecision(target, { deviceKey });
1072
1148
 
1073
- const gate = useReviewGate({ config, decision: decision ?? undefined, storage });
1149
+ // `config` here is the GATE's config (it has an `id`), not the kit transport config, and
1150
+ // `gateStorage` is a SYNCHRONOUS store (`getItem`/`setItem` returning values, not promises) —
1151
+ // the same shape `CoachmarkProvider` takes, not the async adapter `<WireOnboarding storage>` wants.
1152
+ const gate = useReviewGate({ config: { id: "post_onboarding" }, decision: decision ?? undefined, storage: gateStorage });
1074
1153
  ```
1075
1154
 
1076
1155
  It returns the full `{ fire, reason, arm }` on a 2xx and `null` on anything else. That
@@ -1166,7 +1245,7 @@ useEffect(() => {
1166
1245
  fetchReviewDecision(target, { sessionId, deviceKey }).then((d) => setDecision(d ?? undefined));
1167
1246
  }, [revalidation]);
1168
1247
 
1169
- const gate = useReviewGate({ config: { id: "home", minSessions: 2 }, decision, storage });
1248
+ const gate = useReviewGate({ config: { id: "home", minSessions: 2 }, decision, storage: gateStorage });
1170
1249
 
1171
1250
  // …elsewhere, on the triggering action. Awaiting it means the re-fetch sees the event:
1172
1251
  await track("journal_done"); // POST stored → revalidation bumps → effect re-fetches → gate can fire
@@ -1245,7 +1324,7 @@ Skip the onboarding half and the purchase events are still valid on their own, t
1245
1324
 
1246
1325
  Three rules govern it:
1247
1326
 
1248
- - **Your key always wins.** Any non-empty `device_key` you pass is forwarded verbatim and nothing is merged over it. Existing integrations see a byte-identical payload.
1327
+ - **Your key always wins.** A `device_key` you pass is forwarded verbatim, untrimmed, and nothing is merged over it — as long as it is a string with at least one non-whitespace character, which is the kit's entire test for "the host supplied one" (the trim is used only to decide presence). A non-string (the `userContext` type also allows numbers and booleans) or an all-whitespace value is not a usable join key: it is treated as absent and the auto key is merged over it. Existing integrations that pass a real key see a byte-identical payload.
1249
1328
  - **It needs `storage`.** Without persistence the kit's id is minted fresh on every launch, and a per-launch key is worse than no key: the server counts `min_sessions` by distinct app-opens grouped on `device_key`, so it corrupts the counter instead of leaving the join empty. With no `storage` the kit declines to inject and warns in dev.
1250
1329
  - **You can opt out.** `autoJoinKey={false}` restores the pre-0.12.2 behavior for a host that genuinely wants an unlinked session. The dev warning fires again there, naming the opt-out as the reason nothing was injected.
1251
1330
 
@@ -1,10 +1,10 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-j5gFfJhK.mjs';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CUvTOchb.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-CUvTOchb.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-s5QxA-ci.mjs';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-Bz7G6lno.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-Bz7G6lno.mjs';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.mjs';
5
- import '../types-Cju-1_jT.mjs';
5
+ import '../types-Byx306Kv.mjs';
6
6
  import '../types-BKfpdZzX.mjs';
7
- import '../types-BcmagF6K.mjs';
7
+ import '../types-D_0B0yay.mjs';
8
8
  import 'react';
9
9
  import 'wireai-rn';
10
10
  import 'react-native';
@@ -105,13 +105,18 @@ declare const useScreenTracking: (navigationRef: NavigationRefLike | undefined,
105
105
  * numeric `ts` did exactly that through 0.13.0. This turns that class of loss from something you
106
106
  * discover in a funnel report weeks later into something the first run tells you.
107
107
  *
108
- * SHAPE: four independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
108
+ * SHAPE: five independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
109
109
  * without the others. The report is data, never a thrown error and never a side effect on the host:
110
110
  *
111
111
  * 1. `target` : is there a server URL and a key, and do they look like a key and a URL?
112
112
  * 2. `reachability`: is the server actually there? (`GET /v1/events/contract`, public and cheap)
113
113
  * 3. `storage` : can the offline queue persist? (a write / read / delete probe)
114
114
  * 4. `round_trip` : does a REAL event survive REAL server validation? (a `dry_run` POST)
115
+ * 5. `join_key` : will those events be JOINABLE? (read-only; needs `join`)
116
+ *
117
+ * Checks 1-4 all answer "does an event leave and get accepted". NONE of them answers "can it be
118
+ * joined", and an integration with no `user_context.device_key` writes every event, passes every
119
+ * one of those four, and still reports a permanent ZERO in the activated funnel. That is check 5.
115
120
  *
116
121
  * NEVER THROWS, under any input, any network condition, or any hostile response object. A doctor
117
122
  * that can crash the screen it is diagnosing is worse than no doctor.
@@ -131,6 +136,9 @@ declare const useScreenTracking: (navigationRef: NavigationRefLike | undefined,
131
136
  * const report = await wireDoctor({
132
137
  * target: { serverUrl: "https://api.example.com", apiKey: DRIVELINE_KEY },
133
138
  * storage: AsyncStorage,
139
+ * // The same values you hand <WireOnboarding>. Omit `join` and the join_key check FAILS,
140
+ * // because a report that never looked at the join cannot honestly read green.
141
+ * join: { appId: WIRE_APP_ID, userContext },
134
142
  * });
135
143
  * console.log(report.ok, report.checks);
136
144
  */
@@ -138,7 +146,8 @@ declare const useScreenTracking: (navigationRef: NavigationRefLike | undefined,
138
146
  /** One diagnosis. `name` is stable and machine-readable; `detail` is for a human reading a console. */
139
147
  type WireDoctorCheck = {
140
148
  /**
141
- * Stable id: `target` | `reachability` | `storage` | `round_trip` | `dev_only` | `internal_error`.
149
+ * Stable id: `target` | `reachability` | `storage` | `round_trip` | `join_key` | `dev_only` |
150
+ * `internal_error`.
142
151
  *
143
152
  * `dev_only` means ONE thing and only that thing: `__DEV__` is unset or false, so nothing ran.
144
153
  * `internal_error` is the separate catch-all for a failure that got past every check's own
@@ -155,6 +164,19 @@ type WireDoctorReport = {
155
164
  ok: boolean;
156
165
  checks: WireDoctorCheck[];
157
166
  };
167
+ /**
168
+ * What the join check needs: the same three values the host hands `<WireOnboarding>`. Pass the very
169
+ * props you pass the component — anything reconstructed here would diagnose a DIFFERENT integration
170
+ * than the one that ships.
171
+ */
172
+ type WireDoctorJoinTarget = {
173
+ /** The same `config.appId`. It namespaces the persisted auto key, so the wrong one reads the wrong slot. */
174
+ appId?: string;
175
+ /** The same `userContext` prop. Only `device_key` is read; nothing else is inspected or reported. */
176
+ userContext?: Record<string, string | number | boolean>;
177
+ /** The same `autoJoinKey` prop. `false` is the documented opt-out from the kit's auto-join. */
178
+ autoJoinKey?: boolean;
179
+ };
158
180
  /** Input for {@link wireDoctor}. `storage` is optional: without it the queue runs in-memory only. */
159
181
  type WireDoctorOptions = {
160
182
  /** The same `{serverUrl, apiKey}` the kit is configured with. */
@@ -164,6 +186,11 @@ type WireDoctorOptions = {
164
186
  * check reports the DEGRADED in-memory mode rather than failing.
165
187
  */
166
188
  storage?: WireOnboardingStorage;
189
+ /**
190
+ * The join-key inputs. OMITTING THIS FAILS THE `join_key` CHECK — deliberately, and see
191
+ * {@link checkJoinKey} for why a skipped join check may not read as a pass.
192
+ */
193
+ join?: WireDoctorJoinTarget;
167
194
  };
168
195
  /**
169
196
  * Run the full diagnosis. Resolves a report; NEVER throws and NEVER rejects.
@@ -283,11 +310,6 @@ type Analytics = {
283
310
  */
284
311
  declare const createAnalytics: (config: CreateAnalyticsConfig, options?: AnalyticsOptions) => Analytics;
285
312
 
286
- /**
287
- * Build a per-mount analytics instance. `config`/`options` are read once at first render (the
288
- * instance is stable for the component's lifetime, held in a ref). Returns the {@link Analytics}
289
- * surface so the component can `track` / `screen` / `identify` and drive `notifyOnline` on reconnect.
290
- */
291
313
  declare const useAnalytics: (config: CreateAnalyticsConfig, options?: AnalyticsOptions) => Analytics;
292
314
 
293
- export { type Analytics, type AnalyticsOptions, type AnalyticsProps, ClientEventTarget, type CreateAnalyticsConfig, EventQueueOptions, type NavigationRefLike, type NavigationRouteLike, type NavigationStateLike, type ScreenTracker, type ScreenTrackerOptions, type WireDoctorCheck, type WireDoctorOptions, type WireDoctorReport, createAnalytics, createScreenTracker, getActiveRouteName, screenTrackingHandler, useAnalytics, useScreenTracking, wireDoctor };
315
+ export { type Analytics, type AnalyticsOptions, type AnalyticsProps, ClientEventTarget, type CreateAnalyticsConfig, EventQueueOptions, type NavigationRefLike, type NavigationRouteLike, type NavigationStateLike, type ScreenTracker, type ScreenTrackerOptions, type WireDoctorCheck, type WireDoctorJoinTarget, type WireDoctorOptions, type WireDoctorReport, createAnalytics, createScreenTracker, getActiveRouteName, screenTrackingHandler, useAnalytics, useScreenTracking, wireDoctor };