@wireai/activation 0.15.0 → 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 (67) hide show
  1. package/AGENTS.md +95 -20
  2. package/CHANGELOG.md +707 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +100 -25
  5. package/dist/analytics/index.d.mts +32 -10
  6. package/dist/analytics/index.d.ts +32 -10
  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 +14 -0
  12. package/dist/coachmarks/index.d.ts +14 -0
  13. package/dist/coachmarks/index.js +73 -20
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +73 -20
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-orZy5p1e.d.mts → currentSession-Bz7G6lno.d.mts} +25 -35
  18. package/dist/{currentSession-CFSRZ2wg.d.ts → currentSession-z-CZ55ad.d.ts} +25 -35
  19. package/dist/index.d.mts +5 -2
  20. package/dist/index.d.ts +5 -2
  21. package/dist/index.js +125 -36
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +125 -36
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +0 -13
  26. package/dist/questionnaire/index.d.ts +0 -13
  27. package/dist/questionnaire/index.js +154 -43
  28. package/dist/questionnaire/index.js.map +1 -1
  29. package/dist/questionnaire/index.mjs +155 -44
  30. package/dist/questionnaire/index.mjs.map +1 -1
  31. package/dist/reviews/index.js +159 -91
  32. package/dist/reviews/index.js.map +1 -1
  33. package/dist/reviews/index.mjs +160 -92
  34. package/dist/reviews/index.mjs.map +1 -1
  35. package/dist/showcase/index.js +59 -18
  36. package/dist/showcase/index.js.map +1 -1
  37. package/dist/showcase/index.mjs +60 -19
  38. package/dist/showcase/index.mjs.map +1 -1
  39. package/llms.txt +9 -9
  40. package/package.json +6 -9
  41. package/src/analytics/currentSession.ts +141 -4
  42. package/src/analytics/index.ts +6 -1
  43. package/src/analytics/reportClientEvent.ts +19 -10
  44. package/src/analytics/useAnalytics.ts +74 -15
  45. package/src/analytics/wireDoctor.ts +152 -7
  46. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  47. package/src/coachmarks/runtime.ts +53 -0
  48. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  49. package/src/context/deviceId.ts +49 -15
  50. package/src/features/WireFeaturesProvider.tsx +72 -12
  51. package/src/features/fetchWireFeatures.ts +49 -11
  52. package/src/features/useWireFeatures.ts +39 -3
  53. package/src/identity/identityRecord.ts +15 -2
  54. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  55. package/src/questionnaire/transport.ts +22 -8
  56. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  57. package/src/reviews/ReviewGate.tsx +39 -0
  58. package/src/reviews/idempotency.ts +38 -0
  59. package/src/reviews/runtime.ts +39 -10
  60. package/src/reviews/transport.ts +22 -8
  61. package/src/reviews/useReviewGate.ts +57 -7
  62. package/src/session-analytics/lifecycle.ts +16 -0
  63. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  64. package/src/session-analytics/useSessionStart.ts +22 -2
  65. package/src/showcase/FeatureShowcase.tsx +50 -3
  66. package/src/types.ts +5 -4
  67. 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.15.0.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
  }
@@ -205,20 +245,22 @@ returns the same config. `options.appRoot` defaults to `process.cwd()`.
205
245
 
206
246
  `npm run pack:tarball` runs the build (via `prepack`) and produces
207
247
  `wireai-activation-x.y.z.tgz` containing `dist` + `src` + `metro` + README +
208
- CHANGELOG. The primary channel is **npm** (`npm install @wireai/activation`); a
248
+ CHANGELOG. The primary channel is **npm** (the install line above); a
209
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()}
@@ -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}
@@ -389,6 +431,7 @@ following the live per-open session the kit registers from `app.session_started`
389
431
  collapse onto a single device-scoped id: one "first open" for the life of the install, however many
390
432
  times the user comes back. The kit warns about it in dev builds.
391
433
 
434
+ <!-- doc-check: skip - two ALTERNATIVE call shapes shown side by side; one file cannot declare the same const twice -->
392
435
  ```tsx
393
436
  const analytics = createAnalytics({ serverUrl, apiKey, storage, appId }); // ✅ follows each app-open
394
437
  const analytics = createAnalytics({ serverUrl, apiKey, sessionId: myId }); // ⚠️ frozen for good
@@ -571,6 +614,7 @@ that name (so an app can ship brand SVG/Lottie the kit never imports), and other
571
614
  renders the backend `imageUrl` via RN `<Image>`. Leave a dashboard image's URL empty
572
615
  to mean "the app provides this one by name."
573
616
 
617
+ <!-- doc-check: skip - a single JSX ATTRIBUTE, not a statement; there is no expression form for it -->
574
618
  ```tsx
575
619
  illustrations={{
576
620
  "before-after": <BeforeAfterIllustration width={300} />, // your SVG component
@@ -613,6 +657,7 @@ waiting on every shipped app to update.
613
657
  native rebuild (most Expo apps already ship it). Skip it and the cards render fine without them.
614
658
  Or skip it and bring your own brand nodes, keyed by the same vocabulary names:
615
659
 
660
+ <!-- doc-check: skip - a single JSX ATTRIBUTE, not a statement; there is no expression form for it -->
616
661
  ```tsx
617
662
  icons={{
618
663
  instagram: <BrandInstagram />, // override one name with your own mark
@@ -682,6 +727,7 @@ An `afterCard` the flow never reaches **clamps to `"beforeEnd"`** instead of sil
682
727
 
683
728
  The kit ships English good enough to ship. Override any single line and the rest of the default stays:
684
729
 
730
+ <!-- doc-check: skip - one OBJECT PROPERTY of a permission screen, not a statement -->
685
731
  ```tsx
686
732
  copy: { title: "Never miss a session", primaryLabel: "Turn on reminders" }
687
733
  ```
@@ -707,7 +753,15 @@ The gap between `wire_permission_screen_shown` and `wire_permission_primer_accep
707
753
  ## Backend coupling
708
754
 
709
755
  The backend (`wire-rn/examples/dynamic-onboarding/server`) is the source of truth
710
- 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:
711
765
 
712
766
  1. add it to `app/wire.py` `KNOWN_COMPONENTS` + `COMPONENT_DOCS`, **and**
713
767
  2. add its name to the tenant's `allowed_components` (the `register_*.py` script),
@@ -745,12 +799,14 @@ useEffect(() => {
745
799
  wireDoctor({
746
800
  target: { serverUrl: DRIVELINE_SERVER_URL, apiKey: DRIVELINE_API_KEY },
747
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 },
748
804
  }).then((report) => console.log(report.ok, report.checks));
749
805
  }, []);
750
806
  ```
751
807
 
752
808
  It resolves `{ ok, checks: [{ name, ok, detail }] }` and **never throws**, whatever the target, the
753
- storage adapter or the network does. Four independent checks:
809
+ storage adapter or the network does. Five independent checks:
754
810
 
755
811
  | `name` | What it proves |
756
812
  |---|---|
@@ -758,9 +814,23 @@ storage adapter or the network does. Four independent checks:
758
814
  | `reachability` | `GET /v1/events/contract` answers, so the URL really points at a Wire server. |
759
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. |
760
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`. |
761
818
 
762
819
  A `ts`-shaped drift shows up as `skipped=1` carrying the server's own `field: ts`.
763
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
+
764
834
  **Dev-only by contract.** The `__DEV__` guard is the first statement in the function, so a release
765
835
  build does nothing at all: no network call, no storage write. In that case the report says it was
766
836
  skipped rather than reporting a pass it never earned. A host that never imports `wireDoctor` does not
@@ -861,9 +931,9 @@ storage, so the app keeps only its own domain gates. Steps must be a memoized
861
931
  array; step callbacks are held in a ref (parent re-renders never re-fire a step):
862
932
 
863
933
  ```tsx
864
- import { useCoachmarkTour } from "@wireai/activation/coachmarks";
934
+ import { useCoachmarkTour, type CoachmarkStep } from "@wireai/activation/coachmarks";
865
935
 
866
- const steps = useMemo(
936
+ const steps = useMemo<CoachmarkStep[]>(
867
937
  () => [
868
938
  { id: "feed_scroll", message: "Swipe to explore.", gesture: "swipe_up" },
869
939
  { id: "group_tab", message: "Your groups live here.", anchorId: "group_tab",
@@ -955,6 +1025,7 @@ const catalog = [
955
1025
 
956
1026
  // Sequence: WireOnboarding → onComplete(result) → select from answers → FeatureShowcase → app
957
1027
  <WireOnboarding
1028
+ config={config}
958
1029
  onComplete={(result) => {
959
1030
  persist(result.answers);
960
1031
  setSlides(selectShowcaseSlides(catalog, idsFromAnswers(result.answers)));
@@ -1033,6 +1104,7 @@ own analytics; the four moments are `review_prompt_shown`, `review_rating_select
1033
1104
  `stars`), `store_review_requested`, and `review_feedback_submitted`. No PII rides in events.
1034
1105
  Feedback text goes only in the POST body.
1035
1106
 
1107
+ <!-- doc-check: skip - a COMPONENT BODY fragment: the `return` needs an enclosing component, which the surrounding prose supplies -->
1036
1108
  ```tsx
1037
1109
  import { ReviewGate, useReviewGate } from "@wireai/activation/reviews";
1038
1110
 
@@ -1074,7 +1146,10 @@ import { fetchReviewDecision, useReviewGate } from "@wireai/activation/reviews";
1074
1146
  // so deviceKey is the identity that matters. sessionId is optional.
1075
1147
  const decision = await fetchReviewDecision(target, { deviceKey });
1076
1148
 
1077
- 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 });
1078
1153
  ```
1079
1154
 
1080
1155
  It returns the full `{ fire, reason, arm }` on a 2xx and `null` on anything else. That
@@ -1170,7 +1245,7 @@ useEffect(() => {
1170
1245
  fetchReviewDecision(target, { sessionId, deviceKey }).then((d) => setDecision(d ?? undefined));
1171
1246
  }, [revalidation]);
1172
1247
 
1173
- const gate = useReviewGate({ config: { id: "home", minSessions: 2 }, decision, storage });
1248
+ const gate = useReviewGate({ config: { id: "home", minSessions: 2 }, decision, storage: gateStorage });
1174
1249
 
1175
1250
  // …elsewhere, on the triggering action. Awaiting it means the re-fetch sees the event:
1176
1251
  await track("journal_done"); // POST stored → revalidation bumps → effect re-fetches → gate can fire
@@ -1,6 +1,6 @@
1
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-orZy5p1e.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-orZy5p1e.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
5
  import '../types-Byx306Kv.mjs';
6
6
  import '../types-BKfpdZzX.mjs';
@@ -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 };
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-xqqSFqxs.js';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CFSRZ2wg.js';
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-CFSRZ2wg.js';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-z-CZ55ad.js';
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-z-CZ55ad.js';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.js';
5
5
  import '../types-tdATL5z0.js';
6
6
  import '../types-BKfpdZzX.js';
@@ -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 };