@wireai/activation 0.13.6-next.0 → 0.14.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 (42) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +258 -3
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +41 -35
  5. package/dist/analytics/index.d.mts +9 -2
  6. package/dist/analytics/index.d.ts +9 -2
  7. package/dist/analytics/index.js +56 -12
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +56 -12
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-Cs3lweFZ.d.mts → currentSession-CUvTOchb.d.mts} +62 -7
  12. package/dist/{currentSession-DD6dKB0i.d.ts → currentSession-CW_5Mq4O.d.ts} +62 -7
  13. package/dist/index.d.mts +26 -6
  14. package/dist/index.d.ts +26 -6
  15. package/dist/index.js +635 -544
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +635 -544
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js.map +1 -1
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs.map +1 -1
  23. package/llms.txt +3 -3
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +23 -5
  26. package/src/WireOnboarding.tsx +4 -3
  27. package/src/activation/useWireActivation.ts +14 -1
  28. package/src/activation/wireActivation.ts +68 -2
  29. package/src/analytics/analyticsFacade.ts +24 -0
  30. package/src/analytics/eventQueue.ts +106 -11
  31. package/src/analytics/reportClientEvent.ts +31 -7
  32. package/src/analytics/useAnalytics.ts +17 -0
  33. package/src/context/deviceId.ts +10 -3
  34. package/src/permissions/permissionMemory.ts +12 -1
  35. package/src/session/persistedSession.ts +32 -8
  36. package/src/session-analytics/lifecycle.ts +26 -5
  37. package/src/session-analytics/reportSessionStart.ts +14 -10
  38. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  39. package/src/session-analytics/useSessionStart.ts +57 -15
  40. package/src/types.ts +43 -8
  41. package/src/utils/readPlan.ts +26 -10
  42. package/src/utils/readProgress.ts +5 -0
package/AGENTS.md CHANGED
@@ -51,7 +51,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
51
51
  - `WIRE_PERMISSION_EVENTS`, `resolvePermissionCopy(...)`: the canonical permission-funnel names and the shipped rationale copy behind `permissionScreens` (see the prop below). The kit imports no native permission module; the host injects `request`.
52
52
  - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
53
53
  - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Every `<WireOnboarding>` needs it (see step 7).
54
- - `resolveAutoDeviceKey({ appId, storage })`: the kit's own persisted per-install `device_key`, for a host that owns none.
54
+ - `resolveAutoDeviceKey({ appId, storage })`: the kit's own per-install `device_key`, read synchronously. Use it for a stamp on an ordinary event, never as the cross-launch join key on `<WireOnboarding>` (it cannot tell you whether the id persists, see the join-key section). `hydrateDeviceIdentity({ appId, storage })` is the awaited, durability-reporting sibling.
55
55
  - `useLifecycleEvents(config, options)`: the app-root lifecycle hook (see step 8). `useSessionStart` / `reportSessionStart` are the counter-owning alternatives.
56
56
  - Types: `OnboardingTheme`, `OnboardingResult`, `OnboardingEvent`, `WireOnboardingConfig`, `WireOnboardingProps`, `StepValidator`, `OnboardingCopy`, `IllustrationRegistry`.
57
57
 
@@ -60,7 +60,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
60
60
  | Prop | Type | Required | Notes |
61
61
  |---|---|---|---|
62
62
  | `config` | `WireOnboardingConfig` | yes | A2A transport + tenant. See the `config` fields table below. |
63
- | `onComplete` | `(result: { answers, raw }) => void` | yes | Terminal recap CTA tapped. Persist `answers` via your profile-update path, then navigate on. |
63
+ | `onComplete` | `(result: OnboardingResult) => void` | yes | Terminal recap CTA tapped. Persist `answers` via your profile-update path, then navigate on. `result` always has `answers` + `raw`; since 0.13.6 it also carries `plan` (the backend's onboarding plan, whenever the backend sends one; the kit never infers it from the flow type) and `variant` (the assigned experiment arm) when the backend sent them. Both keys are ABSENT otherwise, so an integration written before 0.13.6 sees no change. The kit does not interpret either one: validate `plan` before applying it. |
64
64
  | `theme` | `Partial<OnboardingTheme>` | no | Colors + font family names + radius/spacing, deep-merged over a neutral default. For dark/light, pass a different theme per app theme-state. |
65
65
  | `components` | `WireAIComponent[]` | no | Override registered cards (default `onboardingComponents`). |
66
66
  | `illustrations` | `Record<string, ReactNode>` | no | Host artwork for `InterstitialCard`, keyed by name. `{ ...defaultIllustrations, ...myArt }`. |
@@ -112,8 +112,8 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
112
112
  4. **Config**: set `EXPO_PUBLIC_WIREAI_API_KEY` / `_SERVER_URL` / `_APP_ID`; build via `wireConfigFromEnv({ appId })`.
113
113
  5. **Fonts**: host must load the font families named in `theme.fonts.regular/medium/bold` (e.g. `expo-font`), else text falls back to system font.
114
114
  6. **Theme**: `themeFromBrand({ primary })` or a full `Partial<OnboardingTheme>`; for dark/light, pick the theme by the app's theme state.
115
- 7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate. Pass the join key: `userContext={activationJoinContext(deviceKey)}`, or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` when the app owns no device id. Skipping it leaves the `activated` funnel permanently empty and reports no error.
116
- 8. **Lifecycle (do not skip)**: mount `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` ONCE at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id that `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the kit's persisted auto `device_key` on `app.session_started`, which is what the server's `min_sessions` rule counts. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`.
115
+ 7. **Render + continuation**: drop `<WireOnboarding>` into the signup flow; `onComplete` → persist + navigate, `onSkip` → navigate. Pass the join key: `userContext={activationJoinContext(deviceKey)}`, or, when the app owns no device id, pass a working `storage` and leave `userContext` alone so the kit injects its own (never hand-build it from `resolveAutoDeviceKey`, see the join-key section). Skipping both leaves the `activated` funnel permanently empty and reports no error.
116
+ 8. **Lifecycle (do not skip)**: mount `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` ONCE at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id that `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the kit's persisted auto `device_key` on `app.session_started`, which is what the server's `min_sessions` rule counts. That last part needs storage that actually persists: since 0.14.0 an adapter that throws or rejects is treated exactly like no storage, so the events fire with no auto key rather than a per-launch one that corrupts the count. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and since 0.14.0 `useSessionStart` follows the same auto-key rule.
117
117
  9. **Gate**: wrap behind a flag (env for dev, remote config for prod) **AND** `config != null`; fall through to existing onboarding when off/unconfigured. Decide who sees it (e.g. new signups only) in host nav logic.
118
118
 
119
119
  ## Review firing (do not hand-roll the decision fetch)
@@ -174,17 +174,29 @@ and activation surfaces auto-mint and stamp it on every event, and onboarding ge
174
174
  `userContext={{ deviceKey }}`, because the server's device lookup reads `device_key` and a
175
175
  misspelled bucket produces an empty funnel instead of an error.
176
176
 
177
- **If the host owns no device id, read the kit's.** Do not leave the onboarding side blank.
178
- `resolveAutoDeviceKey({ appId, storage })` returns the exact id `createAnalytics` /
179
- `createWireActivation` auto-mint and persist for this install, so both halves of the join agree:
177
+ **If the host owns no device id, let the kit supply it.** Do not leave the onboarding side blank and
178
+ do not build the key yourself. Pass a working `storage` and the kit injects the exact id
179
+ `createAnalytics` / `createWireActivation` auto-mint and persist for this install, so both halves of
180
+ the join agree:
180
181
 
181
182
  ```tsx
182
- import { resolveAutoDeviceKey, activationJoinContext } from "@wireai/activation";
183
+ import { activationJoinContext } from "@wireai/activation";
183
184
 
184
- const deviceKey = resolveAutoDeviceKey({ appId, storage }); // the SAME id the analytics side stamps
185
+ // The app owns a device id: pass it.
185
186
  <WireOnboarding config={config} userContext={activationJoinContext(deviceKey)} ... />
187
+
188
+ // The app owns none: pass `storage` and leave `userContext` alone. The kit injects the same id the
189
+ // analytics side stamps, after awaiting the storage read AND confirming the id actually persists.
190
+ <WireOnboarding config={{ ...config, storage }} ... />
186
191
  ```
187
192
 
193
+ ⛔ Do NOT hand-build that key with `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`.
194
+ `resolveAutoDeviceKey` is synchronous by contract, so it returns a freshly minted id and adopts the
195
+ persisted one a storage read later, and it cannot report whether the id survives the launch at all.
196
+ Since 0.14.0 every kit surface that puts a device key on the wire as a cross-launch join awaits
197
+ `hydrateDeviceIdentity` and refuses a non-durable record; a host that really wants the value in hand
198
+ should do the same rather than the sync call.
199
+
188
200
  Always pass `storage`. Without it the id is per-LAUNCH, not per-install, and a per-launch key makes
189
201
  every open look like a new device, which breaks `min_sessions` and A/B arm stickiness as surely as
190
202
  no key breaks the join. The same rule governs the lifecycle wiring: `useLifecycleEvents` falls back
package/CHANGELOG.md CHANGED
@@ -3,6 +3,252 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
+ ## [0.14.0] - 2026-08-17
7
+
8
+ A durability release, in two rounds. Round 1 stopped the two counted lifecycle events from riding a
9
+ per-launch device key. Round 2 came out of a fresh-eyes audit of the whole package under a
10
+ reproduction-first rule: eight findings, seven reproduced with a failing test before anything was
11
+ changed, one reproduced and deliberately left unfixed because its fix is a design ruling.
12
+
13
+ **MINOR, not patch, and only because of new API.** Every behaviour change here is a bug fix; the
14
+ version moved because four public surfaces gained members: `EventQueue.dispose()`,
15
+ `Analytics.dispose()`, `WireActivation.dispose()` and `WireActivationConfig.sink`. Nothing was
16
+ removed, nothing was renamed, and no existing call signature changed.
17
+
18
+ ### Fixed
19
+
20
+ - **The event queue has a teardown, and needs one.** A queue claimed a persisted storage slot and
21
+ armed a backoff timer, and nothing ever gave either back — "already claimed" was read as "held by
22
+ a LIVE one", which is liveness nothing tracked. Two consequences, both silent. A remount (nav,
23
+ Fast Refresh, StrictMode's double-invoke, an `appId` that arrives async) rotated the replacement
24
+ onto `…#2`, so the whole launch persisted into a slot the NEXT launch never reads, and the `#N`
25
+ keys grew without bound. Worse, on the explicit-key path both queues address the SAME slot: the
26
+ dead one's retry timer woke up, drained, emptied, and `removeItem`d the live queue's persisted
27
+ `app.session_started`. (`unrefTimer` is a Node affordance; under Hermes that timer is fully
28
+ alive.) `dispose()` clears the timer, releases the claim and makes the queue inert;
29
+ `useLifecycleEvents`, `useAnalytics` and `useWireActivation` all call it on unmount and before
30
+ replacing an instance.
31
+
32
+ - **A storage read that TIMED OUT is no longer read as "nothing stored".** `withTimeout` resolved
33
+ `undefined` at the ceiling, which is byte-identical to a genuine empty read, and two callers then
34
+ did something irreversible with the guess. `loadPersistedSession` minted a new session id and
35
+ wrote it OVER the still-valid one it had not managed to read (the server then sees a second
36
+ `session_started` and back-fills the first as a phantom drop, the exact inflation that module
37
+ exists to prevent). `reportFirstOpen` treated the unread flag as "never fired" and emitted
38
+ `app.first_open` again on an install that already had one. Both now carry the distinct
39
+ `READ_TIMED_OUT` sentinel the event queue already had, and do nothing destructive on a timeout.
40
+
41
+ The same rule now covers an UNREADABLE flag (a locked / full / permission-denied adapter): that
42
+ fails on every launch, so emitting made `app.first_open` fire on every launch of that install,
43
+ counting one device as an unbounded number of installs. The kit's standing rule for an
44
+ unverifiable persistence answer, set in 0.13.0 and re-applied throughout this release, is to
45
+ decline rather than emit something that corrupts a metric. A host that wants `first_open` on a
46
+ device with no working storage should pass no `storage` at all, which is the documented degraded
47
+ mode and still fires once per process.
48
+
49
+ - **`Analytics.reset()` can no longer be undone by the read that was already in flight.** The
50
+ construction-time user-id hydration adopted its answer under `if (saved && !boundUserId)`, and a
51
+ logout is the one case where an empty binding is deliberate — so a read landing after `reset()`
52
+ re-bound the user who had just logged out, onto every subsequent event. `reset()` now marks the
53
+ hydration superseded.
54
+
55
+ - **The offline size cap no longer spends the funnel-denominator events first.** The buffer is
56
+ drop-oldest at 200, and at an app-open the oldest events in it are exactly `app.first_open` and
57
+ `app.session_started`. A host on the documented shared-sink wiring that went offline for a long
58
+ session pushed past the cap on screen views alone and evicted the open itself. Eviction now spends
59
+ ORDINARY events first; the cap stays a hard ceiling, so a buffer of nothing but `app.*` events
60
+ still evicts oldest-first rather than growing.
61
+
62
+ - **`wire.track()` no longer throws the event away when the POST fails.** It is the ACTION reporter,
63
+ called at exactly the moments a phone is offline, and a failed POST used to be the end of that
64
+ event's life — so the server-side trigger keyed on that `question_key` simply never fired for that
65
+ user. A failed send now goes to a durable buffer: your queue if you pass the new `sink`, otherwise
66
+ one this instance opens on the FIRST failure and never before. `track` still resolves `false` and
67
+ still does not revalidate, because the caller asked whether the server has it now.
68
+
69
+ ⛔ Only a TRANSPORT failure is buffered. An event the server READ and refused (HTTP 200 carrying
70
+ `skipped`) is not retried: a retry is a re-decline, and it would park a poison event in a
71
+ persisted backlog that fails on every drain and every launch. `reportClientEventsOutcome` is the
72
+ internal seam that tells the two apart.
73
+
74
+ - **The unread-backlog guard is armed at construction, not 1500ms later.** It was only set in the
75
+ timeout branch, so for the first 1500ms of every launch the queue wrote freely over a blob it had
76
+ not read, and a drain that emptied in that window `removeItem`d it outright. An adapter that
77
+ resolves a read against its current state then answers `null` and the backlog is gone. The comment
78
+ claiming writes were suppressed "until the read lands" is now true.
79
+
80
+ - **`reportSessionStart`'s direct-POST fallback goes through `buildEventsRequest`.** It hand-built
81
+ its own url, headers and body, so it was the one send path of five that skipped the epoch-ms →
82
+ ISO8601 `ts` conversion — on the event `min_sessions` is counted from, where the failure shape is
83
+ HTTP 200 with `{written: 0, skipped: N}` and no client-visible error. Harmless today (that event
84
+ carries no `ts`), latent tomorrow. The "single choke point" claim in `reportClientEvent.ts` is now
85
+ held to the code by a test rather than by prose.
86
+
87
+ ### Known, reproduced, NOT fixed
88
+
89
+ - **The review/questionnaire gates count PROCESSES, while an app-open is a 30-minute foreground.**
90
+ `currentOpenId()` is pinned for the life of the JS process. On iOS an app is suspended, not
91
+ killed, so a user opening the app daily for a week produces seven `app.session_started` events
92
+ (the SERVER's `min_sessions` advances to 7) while the on-device `wire_review_<id>_sessions` stays
93
+ at 1 forever, and a LOCAL `minSessions: 2` rule can never be met on a phone that is never
94
+ force-quit. It fails in the safe direction (a prompt that never shows), which is why it is not
95
+ being rushed.
96
+
97
+ Reproduced in `src/reviews/sessionCountAcrossOpens.test.ts`, marked `todo`, with the smallest
98
+ coherent fix written out next to it. It is deferred because that fix requires re-declaring the
99
+ `processOpenId` global slot LIVE and rewriting the `globalSlotDiscipline` canary exercise that
100
+ currently asserts this behaviour is correct — a canary written to guard the 2026-07-16 one-star
101
+ incident. Re-ruling it is a design decision, not an implementation detail.
102
+
103
+ - **`loadSettledPermissions` has the same timed-out-read class**, and its caller then persists a set
104
+ grown from `[]`, which can shrink the stored one. Made explicit in code rather than left to an
105
+ accident, but not changed: the fix needs a UX ruling (on an unknown set, does a resumed flow
106
+ re-ask a permission or skip it?), and guessing at that is how a user gets asked twice.
107
+
108
+ ### Changed (docs)
109
+
110
+ - Every host-facing surface stopped recommending the defect class this release fixes. The README
111
+ prop table, `AGENTS.md`, `llms.txt`, `INTEGRATION_PROMPT.md`, the `WireOnboardingProps.userContext`
112
+ docstring and the `__DEV__` missing-join-key warning all told a host with no device id of its own
113
+ to write `userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))}`. That is
114
+ the synchronous, durability-blind read, used as a cross-launch join key. They now say to pass a
115
+ working `storage` and let the kit inject the key it has confirmed persists.
116
+ - `ai_rules/context_map.md` gained the four module rows it was missing (`src/analytics/`,
117
+ `src/session-analytics/`, `src/context/`, `src/device/`). The map is the file the repo's own rules
118
+ tell an agent to read instead of grepping `src/`, and it was silent on the directories this whole
119
+ release lives in.
120
+
121
+ ## [0.13.7] — superseded by 0.14.0, never published
122
+
123
+ The round-1 device-key fixes below shipped as part of **0.14.0**. The entry is kept because it is the
124
+ detailed record of that half of the work.
125
+
126
+ The two events the server counts the funnel DENOMINATOR from stopped riding a per-launch device key.
127
+ `app.first_open` and `app.session_started` are the only events `min_sessions` is computed from (the
128
+ server counts distinct opens grouped by `user_context.device_key`), so a key that differs on every
129
+ launch does not merely leave that counter empty, it caps it at 1 and inflates distinct-device counts
130
+ at the same time. Both lifecycle hooks were doing exactly that, in two different ways.
131
+
132
+ Nothing on the wire changed shape, no export was added or removed, and a host-supplied `deviceKey` is
133
+ byte-identical to 0.13.6 on every path.
134
+
135
+ ### Fixed
136
+
137
+ - **`useLifecycleEvents(...)` no longer stamps the in-memory mint when the storage read settles
138
+ NON-DURABLE.** It already awaited hydration before the mount fire, so a healthy store was correct.
139
+ A locked / full / permission-denied adapter is the case it missed: the read settles degraded, the
140
+ `.then` fired anyway, and the synchronous auto id went onto both counted events. Measured on
141
+ 0.13.6: two launches over a throwing MMKV produced four distinct `wdev_*`.
142
+
143
+ The auto path now resolves through `hydrateDeviceIdentity(...)`, the provenance-carrying read, and
144
+ refuses a `durable: false` outcome, which is the rule `src/context/deviceId.ts` has stated for
145
+ these callers since 0.13.0 and `<WireOnboarding>`'s `autoJoinKey` has obeyed since then. A refusal
146
+ emits both events with NO auto `device_key`, the same branch a config with no `storage` already
147
+ took, never a silenced event. **Every fire path obeys it**, not just the mount one: the foreground
148
+ re-fire after a real background re-runs the same read, so a transient cold-boot storage lock gets a
149
+ fresh attempt on the next open instead of a verdict cached for the process lifetime.
150
+
151
+ - **`useSessionStart(...)` waits for the persisted key before its mount fire.** This hook never got
152
+ the 0.13.3 hydration fix its sibling did, so it stamped a brand-new `wdev_*` on `app.session_started`
153
+ on every launch even on a perfectly HEALTHY store, while every other kit surface adopted the
154
+ persisted id milliseconds later. Any host wiring this path had a structurally unsatisfiable
155
+ `min_sessions` and no error anywhere. It now takes the same awaited, durability-checked auto path as
156
+ `useLifecycleEvents(...)`, on the mount fire and on the foreground re-fire.
157
+
158
+ It is not deprecated and its contract is otherwise unchanged: a host-supplied `deviceKey` and a
159
+ config with no `storage` both still fire synchronously, because there is nothing to read.
160
+
161
+ ### Unchanged
162
+
163
+ - `createAnalytics(...)` / `createWireActivation(...)` keep stamping the synchronous
164
+ `resolveAutoDeviceKey(...)` per event, deliberately. Their documented contract is that they never
165
+ throw and degrade to the in-memory id, they are numerator paths rather than the counted opens, and
166
+ they re-read the registry per event so they adopt the persisted id as soon as it lands.
167
+ - `hydrateAutoDeviceKey(...)` stays exported and behaves identically. No kit surface uses it any more:
168
+ it returns a bare string, and a string cannot say whether it survives the launch, which is the whole
169
+ reason `hydrateDeviceIdentity(...)` exists.
170
+ - Docs swept for the claim this changes: the README lifecycle section, `AGENTS.md` step 8, `llms.txt`
171
+ and `INTEGRATION_PROMPT.md` all said "with `config.storage` it stamps the persisted auto
172
+ `device_key`". Presence of the prop was never the condition. It is now written as what it is: a
173
+ storage adapter that throws or rejects counts as no storage.
174
+
175
+ ## [0.13.6] - 2026-08-09
176
+
177
+ Two things the backend already decides, handed to the host instead of guessed at. Both ride the
178
+ same boundary: the kit reads a payload the server sent, carries it out through `onComplete`, and
179
+ stops. It does not interpret either one, does not validate their contents, does not log them and
180
+ never attaches them to an analytics event.
181
+
182
+ The `plan` half of this release was published as `0.13.6-next.0` on 2026-08-08 with no changelog
183
+ entry. This entry is that debt paid, plus the `variant` half, released together as 0.13.6.
184
+
185
+ ### Added
186
+
187
+ - **`OnboardingResult.plan`, the backend's onboarding plan reaching the host uninterpreted.**
188
+ On the AI path the server appends a SECOND A2A DataPart to the turn it finishes on,
189
+ `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the renderer already
190
+ consumes. A new pure reader, `src/utils/readPlan.ts`, lifts it off the thread and `handleFinish`
191
+ puts it on the result. A run the server finishes without a plan is unchanged: the key stays off
192
+ the result object entirely. Which runs carry a plan is the backend's configuration, and the
193
+ reader never infers it from the flow type.
194
+
195
+ The one thing the reader is allowed to reject is a structural fact: a plan is an object. A scalar,
196
+ an array, a misspelled marker or a missing payload yields `undefined` and the host falls back to
197
+ exactly its pre-plan behaviour. No field is required and no unknown field is stripped, because a
198
+ kit-side schema would make the kit the thing that rejects a plan the host could have used, and the
199
+ server's lenient path adds markers the kit must neither read nor strip. **The host must validate
200
+ the payload before applying it.**
201
+
202
+ **It finds the plan by MARKER, never by position**, and that is the load-bearing part of the read.
203
+ The kit walks the thread newest turn first and, inside a turn, returns the first part whose `kind`
204
+ is `onboarding_plan` and whose payload is an object. Anything else is skipped rather than treated
205
+ as a terminator, so a thread carrying two plans resolves to the last one, the turn the flow
206
+ completes on, and a malformed part can never shadow a good one. Position would be the wrong tool
207
+ here: the SDK collects a task's parts across agent messages LATEST-FIRST, so on a task that
208
+ carries history a previous turn's envelope can sit in the same array as this turn's plan. The
209
+ marker makes that harmless, and it is also why nothing in this pipe promises wire order.
210
+
211
+ It needs an SDK that surfaces the DataParts PAST the first one. The first DataPart is the
212
+ component envelope the renderer already consumes, and it is the one the SDK turns into the string
213
+ it returns for the turn, so the plan is one of the parts left behind after it.
214
+ `wireai-rn@0.2.5-next.1` hands those over on `Message.dataParts`, and a turn that carried nothing
215
+ but the envelope leaves the member ABSENT rather than an empty array (checked against the
216
+ published tarball, not against a paraphrase of it). The peer range here is unchanged at `>=0.2.3`,
217
+ so this degrades rather than breaks: an older SDK, `0.2.4` included, sets no `dataParts`, the kit
218
+ reads no plan, and the host lands on exactly the path a tenant that sends none already takes.
219
+
220
+ - **`OnboardingResult.variant`, the experiment arm the backend assigned, surfaced to the host.**
221
+ The server rides the arm key on the render envelope at `props.progress.variant`, a sibling of
222
+ `step` / `total` / `key` / `slot_id`. The kit had no way to hand it over, so a host that wanted to
223
+ branch on the arm had to INFER it from the shape of the flow (counting answers against a
224
+ hard-coded maximum), an inference that goes silently wrong the day the server changes a flow
225
+ length.
226
+
227
+ `readProgress` whitelists `variant` exactly the way it already whitelists `slot_id`: a non-string
228
+ or absent key reads as "no experiment", never as an error. `OnboardingFlow` latches the arm at
229
+ first sight into a ref that is never cleared, because the assignment is sticky per session but
230
+ only rides the render envelope, and a later card that omits it must not un-assign it.
231
+
232
+ It is also available DURING the flow: the `turn` event carries `variant` from the first card that
233
+ declares one onwards, reusing an event hosts already subscribe to instead of adding a prop for a
234
+ scalar. What an arm key MEANS is the host's decision; the kit does not map it to a name, brand it,
235
+ default it or log it. **It is deliberately never attached to an analytics event either**, on
236
+ either surface, so a host that wants arm-segmented funnels has to put the arm on its own events.
237
+
238
+ ### Unchanged
239
+
240
+ - **Both keys are byte-identical no-ops when the backend sends nothing.** `plan` and `variant` are
241
+ each set on the result ONLY when one was actually seen, so a run without them leaves
242
+ `Object.keys(result)` as `["answers", "raw"]` and `"plan" in result` / `"variant" in result`
243
+ false. Setting a key to `undefined` would pass an `=== undefined` check while breaking every host
244
+ deep-equality assertion, so the canaries assert the KEY SET, not the value. The same rule governs
245
+ the `turn` event.
246
+ - **Neither read can throw.** Both sit on the single code path to `onComplete`, where a throw would
247
+ cost the user the completion of an onboarding they already finished. Every read is runtime-guarded
248
+ and `readPlan` declares its own structural type rather than casting: an `as` cast silences the
249
+ compiler in both directions, including the day the published SDK type disagrees.
250
+ - No new runtime dependency, no export renamed or removed, no change to the exports map.
251
+
6
252
  ## [0.13.5] - 2026-08-03
7
253
 
8
254
  Two halves of one idea: stop finding out about a broken events integration from a funnel report.
@@ -866,7 +1112,11 @@ true, and pins the wire with tests.
866
1112
  - Version reset to `0.1.0` to mark the start of the new package line. The GitHub repository
867
1113
  stays `chohra-med/wireai-onboarding`.
868
1114
 
869
- ## [Unreleased]
1115
+ ## [0.9.0] — 2026-07-17
1116
+
1117
+ *This section carried the title `[Unreleased]` until 2026-08-09, long after it shipped. Everything
1118
+ below went out in 0.9.0; the three analytics/device items it also held belong to 0.8.0 and have been
1119
+ moved to their own section under this one.*
870
1120
 
871
1121
  ### Added: `fetchReviewDecision` — the kit now owns the review firing decision (live user harm)
872
1122
 
@@ -1025,6 +1275,11 @@ All four from Malik's on-device test, 2026-07-16. The **review** gate's chrome i
1025
1275
  button. The 5-star → native store review route (`routeRating`) is untouched.
1026
1276
  - `theme/mergeThemeOver` extracts the helper both gates had copy-pasted.
1027
1277
 
1278
+ ## [0.8.0] — 2026-07-15
1279
+
1280
+ *Also mis-filed under `[Unreleased]` until 2026-08-09. These three items shipped in 0.8.0, one
1281
+ release before everything in the section above.*
1282
+
1028
1283
  ### Added: "device" is now fully automatic — auto-minted, persisted per-install `device_key`
1029
1284
 
1030
1285
  - The analytics façade (`createAnalytics`) now auto-mints a stable, NON-PII per-install `device_key`
@@ -1058,7 +1313,7 @@ All four from Malik's on-device test, 2026-07-16. The **review** gate's chrome i
1058
1313
  - Backward compatible: no existing export renamed or removed; zero new dependencies. The one
1059
1314
  behavior change is intentional — events now carry an auto `device_key` when the host omits one.
1060
1315
 
1061
- ## [0.7.0] — Unreleased
1316
+ ## [0.7.0] — 2026-07-15
1062
1317
 
1063
1318
  ### Added: `WireUserContext` — one rich user-context object → every event's `user_context`
1064
1319
 
@@ -1439,7 +1694,7 @@ All four from Malik's on-device test, 2026-07-16. The **review** gate's chrome i
1439
1694
  `showcaseColorsFromTheme(theme, accent)` (new `src/showcase/showcaseColors.ts`) with a
1440
1695
  regression test asserting the primary button is the accent and never the background.
1441
1696
 
1442
- ## [0.3.0] — Unreleased
1697
+ ## [0.3.0] — 2026-07-12
1443
1698
 
1444
1699
  > Pending release. `0.2.1` is already published on npm (coachmarks + showcase, no
1445
1700
  > identity API), so this identity surface — alongside the unpublished `/reviews`
@@ -41,9 +41,10 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
41
41
  dead-ends.
42
42
  - Forward install attribution if I have it: `wireConfigFromEnv({ appId, metadata: attributionMetadata({...}) })`.
43
43
  - THE JOIN KEY: pass `userContext={activationJoinContext(deviceKey)}`. If my app owns no stable
44
- device id, use `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))`, which returns
45
- the same id the analytics side stamps. Leaving it out makes the `activated` funnel read zero
46
- forever and reports no error.
44
+ device id, pass a working `storage` and leave `userContext` alone: the kit injects the same id
45
+ the analytics side stamps, after confirming it actually persists. Do NOT hand-build it from
46
+ `resolveAutoDeviceKey` (synchronous, and it cannot report durability). Leaving both out makes
47
+ the `activated` funnel read zero forever and reports no error.
47
48
  - Analytics: log via `toAnalyticsEvent(e)` → my logger, using the canonical `wire_onboarding_*` names.
48
49
  7. Navigation: mount the screen in my first-run/signup flow; branch to the static flow when the gate
49
50
  is off or config is missing.
@@ -51,7 +52,9 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
51
52
  ONCE at my app root, above the navigator, before anything else touches analytics. Pass
52
53
  `config.storage`. It is the only path that emits `app.first_open`, it registers the per-open
53
54
  session id every other surface correlates to, and with storage it stamps the kit's persisted auto
54
- `device_key` on `app.session_started`, which is what the server counts `min_sessions` from.
55
+ `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. If my
56
+ storage adapter throws or rejects, the kit treats it as no storage and stamps no auto key at all
57
+ (0.14.0), because a key that changes every launch corrupts that count.
55
58
  `useSessionStart` / `reportSessionStart` are only for a host that already owns an open counter;
56
59
  neither emits `first_open`.
57
60
  9. Event triggers (only if I asked to fire a review/questionnaire off an in-app action): pick the
package/README.md CHANGED
@@ -231,7 +231,7 @@ import { WireOnboarding } from "@wireai/activation";
231
231
  | Prop | Type | Notes |
232
232
  |---|---|---|
233
233
  | `config` | `{ apiKey, serverUrl, appId, metadata?, appVersion? }` | A2A transport; the key resolves the tenant server-side. `appVersion` is host-injected (e.g. `Constants.expoConfig?.version`) and forwarded for analytics segmentation. **Required.** |
234
- | `onComplete` | `(result: { answers, raw }) => void` | Fires when the user taps the terminal recap's CTA. **Required.** |
234
+ | `onComplete` | `(result: OnboardingResult) => void` | Fires when the user taps the terminal recap's CTA. `result` always carries `answers` and `raw`. Since 0.13.6 it may also carry `plan` (the backend's onboarding plan, present whenever the backend sends one; the kit never infers it from the flow type) and `variant` (the experiment arm the backend assigned, when the tenant runs one). Each of those two keys is set ONLY when the backend actually sent one, so a run without them returns exactly the object earlier versions returned: `"plan" in result` and `"variant" in result` both stay false. The kit does not interpret either: **validate `plan` before you apply it.** **Required.** |
235
235
  | `theme` | `Partial<OnboardingTheme>` | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
236
236
  | `illustrations` | `Record<string, ReactNode>` | App artwork for `InterstitialCard`, keyed by name. |
237
237
  | `icons` | `Record<string, ReactNode>` | Your own icon nodes, keyed by the semantic name the AI emits (`{ instagram: <BrandIg/> }`). Checked first, so use it to put your brand mark on a choice card, to add names the vocabulary does not carry, or to get icons at all without installing `@expo/vector-icons`. Anything you leave out falls back to that optional peer, then to no icon. Never a crash. See [Icons](#icons). |
@@ -241,7 +241,7 @@ import { WireOnboarding } from "@wireai/activation";
241
241
  | `onError` | `(err) => void` | Backend error or first-card timeout, fired **after retries are exhausted**, not on the first failure. When `fallbackFlow` is not supplied, the host owns recovery (e.g. route to a static flow). With neither, the kit shows an inline retry. `fallbackFlow` takes precedence over this. |
242
242
  | `fallbackFlow` | `ReactNode` | Your predefined STATIC onboarding, rendered in place once the AI flow fails and retries are exhausted. **Takes precedence over `onError`.** This is the "it can never break your onboarding" guarantee: pass the same flow you shipped before adding Wire AI and the user always keeps moving. |
243
243
  | `maxRetries` | `number` | Consecutive failures to auto-retry before degrading to `fallbackFlow`/`onError`. Default `1` (one silent retry, then degrade). `0` degrades on the first failure. Budget it deliberately: each retry restarts the flow, so with `startTimeoutMs` at its `15000` default the default `maxRetries` puts 30s of loader in front of a user before they see the fallback. A retry also sends a skip sentinel, so the user loses one question and a retry is never recorded as their first answer. |
244
- | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `turn` / `error` recover per-turn analytics since the kit owns the loop. |
244
+ | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `resumed` / `turn` / `error` / `retry` / `fallback` / `permission`, the full union in `OnboardingEvent`. Recover per-turn analytics since the kit owns the loop. See the note under the helpers table. |
245
245
  | `copy` | `Partial<OnboardingCopy>` | Localize the kit's built-in English loader/completion strings. |
246
246
  | `approxScreens` | `number` | Approx total screens — paces the bar as `step/total` (capped, never shown). A backend `progress.total` (the screen budget, sent every turn incl. init) wins, so usually unnecessary. |
247
247
  | `components` | `WireAIComponent[]` | Override the registered cards (defaults to `onboardingComponents`). |
@@ -251,7 +251,7 @@ import { WireOnboarding } from "@wireai/activation";
251
251
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
252
252
  | `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
253
  | `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
- | `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)}` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` if your app owns no device id). 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). |
254
+ | `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
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)). |
256
256
  | `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
257
 
@@ -271,7 +271,7 @@ You rarely hand-roll config, gating, analytics, or attribution — the kit ships
271
271
  | `themeFromBrand({ primary })` · `defaultIllustrations` | One-color theme; dependency-free fallback artwork. |
272
272
  | `motionSpec` | The motion constants behind every kit animation (durations, springs, the design ease). Read-only for hosts. `setMotionDurationScale` / `getMotionDurationScale` are QA tools for the playground's animation-speed knob: never call them in production code. |
273
273
 
274
- > **`onEvent`** fires `started` / `resumed` / `turn` / `error` / `retry` / `fallback` (the kit owns the loop, so this is how you recover per-turn analytics). `resumed` fires INSTEAD of `started` when a persisted session was restored (see the `storage` prop) — don't count both as flow starts. Completion is signalled via `onComplete`, not `onEvent` — log `WIRE_ONBOARDING_EVENTS.completed` there. `started` and `resumed` also carry a `contextId` (the A2A session id): capture it if you might bind a `userId` after the flow finishes (see [User identity](#user-identity)).
274
+ > **`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
275
 
276
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.
277
277
 
@@ -421,6 +421,14 @@ none, and waits for that key to be read back before it fires, so the events the
421
421
  `min_sessions` from carry the id this install keeps. Without storage the id would be per-launch, which
422
422
  would make every open look like a new device, so the fallback deliberately does not engage.
423
423
 
424
+ Since 0.14.0 that also covers a `storage` adapter that is present but cannot persist (locked, full,
425
+ permission denied, so it throws or rejects). The read settles non-durable and the hook declines the
426
+ key rather than stamping the in-memory one. Both events still fire, they just carry no auto
427
+ `device_key`. That is the same verdict `autoJoinKey` reaches on `<WireOnboarding>`, for the same
428
+ reason: a key that changes on every launch corrupts `min_sessions` and inflates distinct-device
429
+ counts, where an absent key only leaves the join empty. A `deviceKey` you supply yourself is never
430
+ touched by any of this.
431
+
424
432
  The two alternatives below are for a host that already owns an open counter. Both emit
425
433
  `app.session_started` only, so `app.first_open` stays empty forever on those paths.
426
434
 
@@ -1310,49 +1318,47 @@ then *improve* Wire AI, not just eyeball it.
1310
1318
 
1311
1319
  ### MCP server (`mcp/`)
1312
1320
 
1313
- A local Model Context Protocol server (`@wireai/mcp`, stdio, TypeScript). It is a
1314
- repo-local dev tool **not** published to npm (excluded from the package `files`
1315
- allowlist). Build it in place:
1316
-
1317
- ```bash
1318
- cd mcp && npm install && npm run build
1319
- ```
1321
+ A Model Context Protocol server (`@wireai/mcp`, TypeScript) that exposes the whole
1322
+ onboarding control plane as agent tools. It runs two ways, same tools either way:
1320
1323
 
1321
- Then add it to your agent client's MCP config:
1324
+ 1. **Hosted, and this is the default.** `https://wireai-mcp.fly.dev/mcp` over Streamable
1325
+ HTTP. You authenticate per request with an `Authorization: Bearer` header, so there is
1326
+ no build step, no login step, and nothing on disk.
1327
+ 2. **Local stdio.** `cd mcp && npm install && npm run build`, then point your client at
1328
+ `mcp/dist/index.js`. Auth is a one-time `node dist/index.js login`, or raw keys via
1329
+ `WIREAI_SERVER_URL` / `WIREAI_API_KEY` / `WIREAI_ADMIN_KEY`. This package is repo-local
1330
+ and **not** published to npm (it is excluded from the kit's `files` allowlist).
1322
1331
 
1323
1332
  ```json
1324
1333
  {
1325
1334
  "mcpServers": {
1326
1335
  "wireai": {
1327
- "command": "node",
1328
- "args": ["<abs-path>/@wireai/activation/mcp/dist/index.js"],
1329
- "env": {
1330
- "WIREAI_SERVER_URL": "https://<your-backend>.fly.dev",
1331
- "WIREAI_API_KEY": "wai_<tenant-key>",
1332
- "WIREAI_ADMIN_KEY": "wai_admin_<operator-key>"
1333
- }
1336
+ "type": "http",
1337
+ "url": "https://wireai-mcp.fly.dev/mcp",
1338
+ "headers": { "Authorization": "Bearer <your-token>" }
1334
1339
  }
1335
1340
  }
1336
1341
  }
1337
1342
  ```
1338
1343
 
1339
- Tools (P1 is read-biased; the only writes are the existing register/update):
1344
+ **Three credential classes, and you probably want the third.** A tenant `wai_` key reads
1345
+ your own app's funnel and files learning reports. The operator admin key reaches the
1346
+ cross-tenant management surface. A **client console token** (minted from a Wire console
1347
+ session) drives your own apps over the `/v1/me/*` console routes, which means a customer
1348
+ runs the full management surface from an agent *without* holding the platform admin key.
1340
1349
 
1341
- | Tool | Auth key | Does |
1342
- |---|---|---|
1343
- | `wireai_get_integration_guide` | none | the current integration recipe |
1344
- | `wireai_list_components` | none | the card vocabulary |
1345
- | `wireai_get_insights` | tenant `WIREAI_API_KEY` | **read** the funnel brief (`/v1/insights.md`), app resolved from the key; a valid "no data yet" brief when empty |
1346
- | `wireai_get_onboarding_config` | admin `WIREAI_ADMIN_KEY` | **read** the live generation config (context, first question, allowed components) |
1347
- | `wireai_register_onboarding_app` | admin `WIREAI_ADMIN_KEY` | create an app + get its key |
1348
- | `wireai_list_onboarding_apps` | admin `WIREAI_ADMIN_KEY` | list apps |
1349
- | `wireai_get_onboarding_app` | admin `WIREAI_ADMIN_KEY` | one app's raw record |
1350
- | `wireai_update_onboarding_app` | admin `WIREAI_ADMIN_KEY` | patch config, no release |
1351
- | `wireai_get_onboarding_analytics` | admin `WIREAI_ADMIN_KEY` | funnel JSON |
1352
- | `wireai_list_reviews` | admin `WIREAI_ADMIN_KEY` | **read** in-app reviews across tenants (stars, feedback, contact) |
1353
-
1354
- Least privilege: for the read-only loop, hand the agent just `WIREAI_SERVER_URL` +
1355
- `WIREAI_API_KEY` (tenant) and skip the admin key. Full detail: [`mcp/README.md`](./mcp/README.md).
1350
+ **Where the tool list lives.** Ask the server: a connected client's `tools/list` is the
1351
+ only copy that is current by construction, and it is already scoped to the credential you
1352
+ presented. `wireai_get_integration_guide` needs no auth at all and returns the recipe.
1353
+ Inside the repo the written table is `mcp/README.md`, and `mcp/src/toolAccess.test.ts`
1354
+ re-derives it from the handlers and goes red when the two disagree.
1355
+
1356
+ This section deliberately does not restate that table. It used to, and the copy drifted to
1357
+ 10 tools of 21 with a "the only writes are register/update" line the code had stopped
1358
+ honouring, because nothing gated it. A duplicated list with no test on it goes stale.
1359
+
1360
+ Least privilege still applies: for the read-only improve loop, hand the agent a tenant
1361
+ credential and skip the admin key entirely.
1356
1362
 
1357
1363
  ### `wire-ai` skill (`.claude/skills/wire-ai`)
1358
1364
 
@@ -1,6 +1,6 @@
1
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-Cs3lweFZ.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-Cs3lweFZ.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';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.mjs';
5
5
  import '../types-Cju-1_jT.mjs';
6
6
  import '../types-BKfpdZzX.mjs';
@@ -268,6 +268,13 @@ type Analytics = {
268
268
  notifyOnline(): void;
269
269
  /** Current pending (in-memory) count. */
270
270
  size(): number;
271
+ /**
272
+ * Tear the instance's event queue down when its owner goes away (see {@link EventQueue.dispose}).
273
+ * Idempotent, never throws. Call it if you build an instance per screen / per mount: the queue
274
+ * claims a persisted storage slot, and a replacement built before the old one released it lands on
275
+ * a rotated `…#2` key that no later launch ever reads. `useAnalytics` does this for you.
276
+ */
277
+ dispose(): void;
271
278
  };
272
279
  /**
273
280
  * Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-B_0SgCBe.js';
2
- import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-DD6dKB0i.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-DD6dKB0i.js';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-CW_5Mq4O.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-CW_5Mq4O.js';
4
4
  import { W as WireOnboardingStorage } from '../types-BpwiRpA8.js';
5
5
  import '../types-h2BZvl1t.js';
6
6
  import '../types-BKfpdZzX.js';
@@ -268,6 +268,13 @@ type Analytics = {
268
268
  notifyOnline(): void;
269
269
  /** Current pending (in-memory) count. */
270
270
  size(): number;
271
+ /**
272
+ * Tear the instance's event queue down when its owner goes away (see {@link EventQueue.dispose}).
273
+ * Idempotent, never throws. Call it if you build an instance per screen / per mount: the queue
274
+ * claims a persisted storage slot, and a replacement built before the old one released it lands on
275
+ * a rotated `…#2` key that no later launch ever reads. `useAnalytics` does this for you.
276
+ */
277
+ dispose(): void;
271
278
  };
272
279
  /**
273
280
  * Create a bound analytics instance. Seeds one correlation `sessionId`, builds an offline-first