@wireai/activation
v0.9.1
Published
Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.
Maintainers
Readme
@wireai/activation
A premium, fully-themable drop-in onboarding kit on top of the open-source
wireai-rn SDK. You give it a tenant
config; it runs an entire AI-driven, backend-orchestrated onboarding — themed
cards, a progress bar, a mid-flow value screen, per-step validation, and a
completion recap — behind one component.
MIT-licensed. Install from npm as
@wireai/activation, or consume from source for live kit-edit (as the apps in this repo do).
Created by Malik Chohra · Code Meet AI newsletter
Sponsored by AI Mobile Launcher and CasaInnov
Where it runs (the moving parts)
| Layer | Repo / path | Role |
|---|---|---|
| SDK (OSS) | wireai-rn | WireAIProvider + ComponentRenderer + useWireAIThread; renders whatever components the host registers; A2A transport. |
| This kit | getwire_ai/@wireai/activation | <WireOnboarding> — themed cards, sparkle loader, countless stepper, InterstitialCard value screen, animated-checkmark completion, validators. The single onboarding UI engine. |
| Backend | wire-rn/examples/dynamic-onboarding/server | Multi-tenant LangGraph engine. Per-app product_context + allowed_components; validates every emission against KNOWN_COMPONENTS. Live on fly dev. |
| Consumers | Production B2C mobile apps | Each hosts <WireOnboarding> with its own theme + illustration registry. |
The kit's required peers are react, react-native, wireai-rn, zod, and
react-native-safe-area-context (>=4.0.0, for safe-area insets, so the host must render a
SafeAreaProvider ancestor, standard in every Expo/RN app). Nothing in the kit forces you into
a native rebuild. Brand artwork is supplied by the host (see Illustrations).
Everything else is an optional peer: @expo/vector-icons (icons on the choice cards, see
Icons), expo-store-review, expo-haptics, expo-blur, react-native-reanimated, and
@blazejkustra/react-native-onboarding. npm never installs them for you. Each one sits behind a
guarded lazy require and falls back to a defined no-op when it is missing. Don't want the icon
library? Pass your own nodes through the icons prop.
How to include it in an app (end-to-end)
Fastest path: run the
wire-rn-integrationClaude skill — it does all of the steps below automatically (detects your conventions, wires Metro, derives a theme, scaffolds the screen, type-checks). The manual steps, in order:
0. Register the app on the backend → get a tenant key. The kit talks to the
multi-tenant backend over A2A; the apiKey is per-app and resolves the tenant
server-side. Create the app in the getwireai console (or run the backend's
register_<app>.py) → it mints a wai_… key. Note the server URL (e.g. the fly
dev/prod URL). Nothing renders without these two.
1. Install the package (plus its peers):
npm install @wireai/activation wireai-rn
# react + react-native are peers (already in any RN/Expo app)⚠️ EAS / cloud builds: install from a git URL or a published registry, not a local
file:../...path outside the app repo — EAS only uploads the app repo, so an externalfile:path fails to resolve in the cloud build. (Localfile:is fine for on-device dev only.)
2. Wire Metro (one call — pins a single React/RN/wireai-rn/zod copy; a 2nd React instance crashes RN):
const { getDefaultConfig } = require("expo/metro-config");
const { withWireOnboarding } = require("@wireai/activation/metro");
module.exports = withWireOnboarding(getDefaultConfig(__dirname));3. Config from env (so you can flip backends without a code change):
EXPO_PUBLIC_WIREAI_API_KEY=wai_…
EXPO_PUBLIC_WIREAI_SERVER_URL=https://…
EXPO_PUBLIC_WIREAI_APP_ID=my-appwireConfigFromEnv({ appId }) reads these and returns null when keys are missing —
use that null as your gate (below).
4. Load your brand fonts in the host. The kit applies theme.fonts.regular/medium/
bold as React Native fontFamily strings — they only work if the host has loaded
those fonts (e.g. expo-font useFonts). If you don't load them, text falls back to
the system font. (The kit ships no fonts by design.)
5. Theme it to match your app — including dark/light. Either derive a palette from
one color (themeFromBrand({ primary })) or pass a full Partial<OnboardingTheme>
(colors + the font family names from step 4 + radius/spacing). To follow the app's
dark/light setting, build two themes and pick by your theme state, for example:
const theme = isDarkMode ? appThemeDark : appThemeLight; // each sets fonts:{regular:'YourFontRegular',…}6. Render it + wire the continuation. Drop <WireOnboarding> into your signup
flow (between username and the next step is typical). On onComplete, persist the
answers through your normal profile-update path, then navigate onward; on onSkip,
just navigate onward:
<WireOnboarding
config={config}
theme={theme}
illustrations={{ ...defaultIllustrations, ...myIllustrations }}
validators={{ username: checkUsernameAvailable }}
onComplete={(r) => { persist(r.answers); goNext(); }}
onSkip={goNext}
onError={fallBackToStaticOnboarding}
onEvent={analytics}
/>7. Gate it so you can turn it on/off without a release. Wrap the screen behind a
flag (env var for dev, remote config for production) AND the config != null
check, so a half-set env or a backend outage never routes a user into a dead screen —
fall through to your existing onboarding instead. Decide who sees it (e.g. new
signups only) in your own navigation logic.
8. Make any new cards emittable on the backend. Registering a card in this kit is not enough — the server validates every emission. See Backend coupling.
No native rebuild for the kit itself (it's pure JS). A rebuild is only needed if your gating/flag mechanism uses a native module (e.g. Firebase Remote Config).
Installation
The kit ships compiled output (dist/, ESM + CJS + types) for external apps,
and still resolves to src/ for the monorepo apps that consume it from source
— driven by the react-native export condition + each app's tsconfig path alias.
1. External apps (install from npm)
Install the package and wire Metro with the one-call helper:
# from npm (public)
npm install @wireai/activation
# …or from a tarball (see Distribution below)
npm install ./@wireai/activation-0.1.3.tgz
# …or over SSH
npm install git+ssh://[email protected]/<org>/@wireai/activation.git// metro.config.js — replaces the hand-written ~25-line block
const { getDefaultConfig } = require("expo/metro-config");
const { withWireOnboarding } = require("@wireai/activation/metro");
module.exports = withWireOnboarding(getDefaultConfig(__dirname));// 3-line minimal usage — themed from a brand color, config from env, default art
import { WireOnboarding, themeFromBrand, wireConfigFromEnv, defaultIllustrations } from "@wireai/activation";
export function Onboarding() {
const config = wireConfigFromEnv({ appId: "my-app" }); // null if env keys missing
if (!config) return null; // …or render your static onboarding
return (
<WireOnboarding
config={config}
theme={themeFromBrand({ primary: "#15B0B0" })}
illustrations={defaultIllustrations}
onComplete={(r) => persist(r.answers)}
/>
);
}wireConfigFromEnv reads EXPO_PUBLIC_WIREAI_API_KEY / EXPO_PUBLIC_WIREAI_SERVER_URL
(+ optional EXPO_PUBLIC_WIREAI_APP_ID) and returns null when keys are missing, so
you gate the AI flow on one falsy check. themeFromBrand derives a full palette from
your primary color; spread your own art over defaultIllustrations to override by
name: illustrations={{ ...defaultIllustrations, ...myIllustrations }}.
2. Source consumers (your monorepo apps)
Same helper, with source pointed at the kit's src/ (adds it to watchFolders
and maps the @wireai/activation specifier to source). Keep the tsconfig path alias.
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config");
const { withWireOnboarding } = require("@wireai/activation/metro");
module.exports = withWireOnboarding(getDefaultConfig(__dirname), {
source: path.join(__dirname, "../../getwire_ai/@wireai/activation/src"),
});withWireOnboarding(config, options) is non-destructive: it pins react /
react-native / wireai-rn / zod to the app's own copy (a 2nd React instance
crashes RN), merges with any extraNodeModules / watchFolders you already set, and
returns the same config. options.appRoot defaults to process.cwd().
The
"react-native"export condition resolves@wireai/activationtosrc/index.tsfor Metro (it transforms the kit's source), whileimport/requireresolve to the builtdist. So both npm consumers and source consumers work; the dist build is additive.
Distribution
npm run pack:tarball runs the build (via prepack) and produces
@wireai/activation-x.y.z.tgz containing dist + src + metro + README +
CHANGELOG. The primary channel is npm (npm install @wireai/activation); a
tarball (npm install ./@wireai/activation-x.y.z.tgz) or a git+ssh://… install
work too for pre-publish or pinned builds.
Quick start
import { WireOnboarding } from "@wireai/activation";
<WireOnboarding
config={{ apiKey, serverUrl, appId: "my-app" }}
theme={myBrandTheme}
illustrations={{ momentum: <MyMomentumSvg /> }}
validators={{ username: checkUsernameAvailable }}
onComplete={(result) => persist(result.answers)}
onSkip={skip}
onError={() => fallBackToStaticOnboarding()}
onEvent={(e) => analytics(e)}
/><WireOnboarding> props
| Prop | Type | Notes |
|---|---|---|
| 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. |
| onComplete | (result: { answers, raw }) => void | Fires when the user taps the terminal recap's CTA. Required. |
| theme | Partial<OnboardingTheme> | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
| illustrations | Record<string, ReactNode> | App artwork for InterstitialCard, keyed by name. |
| validators | Record<string, StepValidator> | Per-step, keyed by base-question key (e.g. username). Blocks advance + shows an inline error. |
| onSkip | () => void | User skipped. |
| onError | (err) => void | Backend error/timeout — host owns recovery (e.g. route to a static flow). Without it, the kit shows an inline retry. |
| onEvent | (e: OnboardingEvent) => void | started / turn / error — recover per-turn analytics since the kit owns the loop. |
| copy | Partial<OnboardingCopy> | Localize the kit's built-in English loader/completion strings. |
| 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. |
| components | WireAIComponent[] | Override the registered cards (defaults to onboardingComponents). |
| startMessage | string | First message to the backend. Default "start". |
| startTimeoutMs | number | Watchdog for the first card. Default 15000. |
| storage | WireOnboardingStorage | Host-injected storage (AsyncStorage-compatible getItem/setItem/removeItem) for session-id persistence: an app KILL mid-onboarding resumes the SAME backend session instead of minting a new one, so the funnel's started count stays honest (no phantom drops). Pass AsyncStorage as-is, or a 3-line MMKV wrapper. Omit for the previous per-mount behavior. Persists the kit's own correlation seed only — answers stay the host's job via onComplete. |
| sessionTtlMs | number | How long a persisted session id stays resumable. Default 3600000 (1h, the backend's session TTL). With storage only. |
| 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. |
| userContext | Record<string, string \| number \| boolean> | Host-injected, non-PII context the app already knows (signup method, referral, plan, a HASHED user id). Forwarded on session metadata + client events for funnel segmentation. No PII (no raw emails/names); primitives only; the server caps size/keys. See Device & user context. |
| 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. |
Helpers (the reusable substrate)
You rarely hand-roll config, gating, analytics, or attribution — the kit ships them:
| Helper | Use |
|---|---|
| wireConfigFromEnv({ appId, metadata? }) | Reads EXPO_PUBLIC_WIREAI_API_KEY/_SERVER_URL/_APP_ID → a config (or null to gate). |
| isOnboardingEnabled({ remote? }) | The one gate: transport present (apiKey + serverUrl). Optional remote kill-switch to disable without a release; no env flag. |
| WIRE_ONBOARDING_EVENTS + toAnalyticsEvent(e) | Map a kit OnboardingEvent to a canonical wire_onboarding_* { name, params }; log it through your own analytics. |
| attributionMetadata(a) | Shape install/ad attribution into { attribution } for config.metadata (forwarded into every backend request). |
| reportClientEvent / reportClientEvents / makeSessionId | Report device-only funnel events to POST /v1/events. <WireOnboarding> does this automatically: dropped on unmount-without-complete, and client_fallback when the AI flow degrades to the static fallback (so the dashboard's fallback-rate counts the whole-flow case). The host must NOT also report its own fallback. |
| identifyOnboarding(opts) / sanitizeUserId / USER_ID_MAX_LENGTH | Bind a userId to a session AFTER the flow (post-registration). identifyOnboarding resolves the session from an explicit contextId (captured from the started/resumed onEvent) or the persisted storage, then posts an identify event. sanitizeUserId trims/caps an id; USER_ID_MAX_LENGTH is the 128-char cap. See User identity. |
| deriveAnswers(messages) | Deterministic { key: value } from the thread (no second LLM call). |
| themeFromBrand({ primary }) · defaultIllustrations | One-color theme; dependency-free fallback artwork. |
| 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. |
onEventfiresstarted/resumed/turn/error/retry/fallback(the kit owns the loop, so this is how you recover per-turn analytics).resumedfires INSTEAD ofstartedwhen a persisted session was restored (see thestorageprop) — don't count both as flow starts. Completion is signalled viaonComplete, notonEvent— logWIRE_ONBOARDING_EVENTS.completedthere.startedandresumedalso carry acontextId(the A2A session id): capture it if you might bind auserIdafter the flow finishes (see User identity).
Integrating with an AI agent? See INTEGRATION_PROMPT.md (copy-paste prompt for Claude Code) and llms.txt. Fastest of all: the wire-rn-integration Claude skill.
Device & user context
Two optional, host-injected inputs let onboarding analytics segment the funnel by device and by what your app already knows about the user. The kit collects the device snapshot itself (no dependency added); everything else you pass.
config.appVersion(string): your app version, e.g.Constants.expoConfig?.version. The kit reads nothing to get it, so it stays dependency-free; you inject it.userContext(Record<string, string | number | boolean>): non-PII context the app already has, like signup method, referral source, plan tier, or a hashed user id. Same host-injection idea asstorage. Do not put raw emails, names, or phone numbers here; pass a hash if you need a user key. Values are primitives only, and the server caps key count/size and drops deep nesting.
Both ride the A2A session-start metadata AND every client event (dropped, client_fallback). Old servers ignore the extra fields, so it is backward compatible.
What collectDeviceContext() collects
Only React Native built-ins and the standard Intl global, each read defensively (missing fields are omitted, it never throws):
| Field | Source |
|---|---|
| platform | Platform.OS |
| osVersion | Platform.Version / iOS osVersion / Android Release |
| brand, model | Android Platform.constants |
| interfaceIdiom | iOS Platform.constants |
| formFactor | phone / tablet (iOS idiom, else shortest screen side >= 600dp) |
| screenWidth, screenHeight, screenScale | Dimensions.get('screen') |
| isRTL | I18nManager.isRTL |
| locale, timeZone | Intl.DateTimeFormat().resolvedOptions() (guarded for Hermes) |
| appVersion | host-injected from config.appVersion |
No tracking (privacy-label-neutral)
No advertising IDs (no IDFA/GAID), no getUniqueId, no fingerprinting APIs. Nothing here identifies a user or device uniquely, so adopting it does not change your App Privacy or Data Safety declarations, and it adds zero dependencies. On the backend the server derives a coarse country from the sent locale/timezone only, and never processes or stores IP addresses.
User identity
userContext segments the funnel; userId goes one step further and lets you reconcile an onboarding session to a real user later (console sessions to your own user table, or GA4 users). It is an OPAQUE, PSEUDONYMOUS string that YOU own, your internal user id, not an email, name, or phone number. Pass a hash if your only key is an email. The kit trims it and caps it at USER_ID_MAX_LENGTH (128) chars; a longer id is truncated, never rejected. Old servers ignore the extra field, so it stays backward compatible.
Users often register during or after onboarding, so userId is fully optional and can arrive late. There are three binding moments:
1. Known at mount. Pass it as a prop and it rides the A2A session-start metadata; the server binds it when it creates the session.
<WireOnboarding config={config} userId={currentUser?.id} onComplete={persist} />2. The user registers mid-session. Change the userId prop and the kit emits an identify client event that attaches the id to the LIVE session. Nothing else to call; re-render with the new value.
const [userId, setUserId] = useState<string | undefined>();
// ...the user signs up mid-flow:
setUserId(newUser.id); // the kit fires `identify` and binds the running session3. The user registers after the flow. Capture the contextId from the started (or resumed) onEvent while onboarding runs, then call identifyOnboarding(...) once you have the id. Completion clears the persisted session, so the captured contextId is the reliable handle.
import { identifyOnboarding } from "@wireai/activation";
const contextIdRef = useRef<string>();
<WireOnboarding
config={config}
onEvent={(e) => {
if (e.type === "started" || e.type === "resumed") contextIdRef.current = e.contextId;
}}
onComplete={persist}
/>;
// later, once the user registers:
await identifyOnboarding({ config, userId: newUser.id, contextId: contextIdRef.current });If you passed storage to <WireOnboarding>, you can omit contextId and hand identifyOnboarding the same storage (plus appId or persistKey) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-contextId path above is the safe one post-completion. identifyOnboarding is fire-and-forget and never throws; it resolves true when it dispatched an identify and false when it could not (no user id, no server url, or no resolvable session).
No PII. userId is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as userContext.
Session mapping (know when a user opens the app again)
Onboarding runs once, on the first launch. Session mapping is the other half: every time the
user opens the app, the kit posts one standard app.session_started event so the backend knows
who came back, when, and for the how-many-th time. That single event does two jobs at once, and
it needs no new server endpoint, because it flows into the SAME event stream the console and
the decision engines already read.
The contract. Each app-open posts one event to POST {serverUrl}/v1/events:
{
"event_type": "app_event",
"question_key": "app.session_started", // the name a trigger matches
"session_id": "wire_lz1_9fk2a0", // a PER-OPEN id, NOT the onboarding contextId
"user_id": "u_42", // your opaque id; omitted on a pre-auth open
"user_context": {
"device_key": "dev_abc", // stable, non-PII; groups this device's opens
"session_count": 3, // your local open counter for this open
"returning": true, // derived from session_count > 1
"app_version": "1.4.2",
"platform": "ios"
}
}The name is carried as question_key on an app_event (not as the event_type), because that
is the shape the server matches a trigger's event against and the shape it stores in the app.
namespace. device_key rides in the non-PII user_context bucket, where the server reads it to
group a device's sessions. The server fills app_id and environment from your key, so you
never send them.
The per-open session id is not the onboarding contextId. Onboarding owns one context id for
its one run. Session mapping fires on every open, so it mints its own fresh session_id each
time. Grouping a user or device over time is done by user_id and device_key, never by
session_id.
Pre-auth to post-auth. A launch before the user signs in sends device_key with no
user_id, so it is a valid device-only session. Once the user authenticates, later opens carry
both user_id and device_key, which links the earlier anonymous opens to the user. (The same
device_key also joins the onboarding session once identifyOnboarding or a session event
carries both ids.)
Two ways to wire it. Both are first-class.
If your app already keeps a session counter (most host apps do), call the plain function from your own "app opened" path and pass the counter value:
import { reportSessionStart } from "@wireai/activation";
// In your app-open effect (or wherever you bump your session counter):
reportSessionStart({
target: { serverUrl: config.serverUrl, apiKey: config.apiKey },
userId: currentUser?.id, // omit before auth
deviceKey, // your stable non-PII device id
sessionCount, // your local counter value for this open
appVersion, // optional
});If you do NOT have a counter, use the hook. It fires on mount and again whenever the app returns to the foreground after at least 30 minutes in the background (a real new open, not a quick app-switch):
import { useSessionStart } from "@wireai/activation";
useSessionStart(
{ serverUrl: config.serverUrl, apiKey: config.apiKey, appVersion },
{ userId: currentUser?.id, sessionCount, deviceKey },
);Both paths are fire-and-forget: they never throw into the UI, never block, and swallow a dead endpoint. A once-per-open guard means a re-render can never double-fire the same open.
The payoff. Because the event lands in the shared stream, the existing decision engines act on it with zero extra code:
- Fire the questionnaire on the user's Nth session. Set the questionnaire's
min_sessionstoN. The server counts DISTINCT opens grouped bydevice_key, so it fires on the Nth open. - Fire on a signal within one open. A trigger
{ event: "app.session_started", min_count: 1 }confirms the current open started before the popup shows. - Per-user retention analytics. The
user_idplussession_countplusreturningon every open is exactly the "when did this user last use the app, and how often" data the console reads.
Testing the flow (no account) — DemoOnboarding
Re-running onboarding normally needs a fresh signup each time (it only shows for new
users). For dev/QA, drop in DemoOnboarding — an on-demand trigger that opens the
flow in a modal, runs it once, and closes (no loop, no account, no navigation side
effects). Put it behind __DEV__ anywhere — a Settings row is typical:
import { DemoOnboarding, wireConfigFromEnv } from "@wireai/activation";
{__DEV__ && (
<DemoOnboarding
config={wireConfigFromEnv({ appId: "my-app" })} // null when Wire AI isn't configured
theme={myTheme}
illustrations={myIllustrations}
label="Demo onboarding (dev)"
/>
)}If config is null (the app hasn't integrated Wire AI yet) it shows a "not configured"
hint instead of the flow. By default the demo does not persist — pass onComplete to
observe the captured answers, or renderTrigger={(open) => <YourButton onPress={open} />}
to use your own button.
Cards
onboardingComponents registers: TextInputCard, SelectionCard,
ChipSelectCard, CardGridSelectCard, NumberStepperCard, StatusCard (terminal recap),
and InterstitialCard — a mid-flow momentum/value screen (Duolingo/Cal-AI style):
an illustration + a line that reflects the user's answers back + a Continue tap.
It is not a question; the backend emits it once mid-flow.
The three choice cards differ only in presentation, and each card's description is what the
model routes on. SelectionCard is a vertical list, for options wordy enough that someone has to
read them. ChipSelectCard packs 4 to 12 short tags into compact pills. CardGridSelectCard puts
2 to 6 visual choices in a two-column grid, icon on top, short label under it ("What brings you
here?"). All three accept an option as a bare string, a {value, label}, or a
{value, label, icon}.
Illustrations
A tenant can configure multiple growth images per app (name + description +
image URL) in the dashboard; the onboarding AI picks the one whose description best
fits each user, and the backend resolves the chosen name to an imageUrl. On the
device, InterstitialCard prefers a host-supplied illustrations registry node for
that name (so an app can ship brand SVG/Lottie the kit never imports), and otherwise
renders the backend imageUrl via RN <Image>. Leave a dashboard image's URL empty
to mean "the app provides this one by name."
illustrations={{
"before-after": <BeforeAfterIllustration width={300} />, // your SVG component
momentum: <MomentumGlyph />,
}}Icons
Options on SelectionCard and CardGridSelectCard can carry an icon, so "Where did you hear
about us?" puts a real Instagram mark next to the Instagram row. The AI emits a semantic name
from a fixed vocabulary, never a raw glyph name from an icon library:
// what the backend emits
{ "component": "CardGridSelectCard", "props": { "title": "What brings you here?", "options": [
{ "value": "wedding", "label": "Wedding day", "icon": "wedding" },
{ "value": "travel", "label": "Travelling", "icon": "travel" }
]}}That indirection is the whole point. The model never learns a library's glyph names, so swapping
or upgrading the icon set is a one-file change in src/icons/vocabulary.ts. Import
WIRE_ICON_NAMES to get the list to paste into your prompt as the allowed values.
Resolution runs in three steps, and every miss is silent:
| Step | Source | When it applies |
|---|---|---|
| 1 | your icons prop | Always checked first. Overrides any name, and accepts names of your own. |
| 2 | @expo/vector-icons | If that optional peer resolves. |
| 3 | nothing | Renders null. |
Step 3 is a feature, not a gap. An icon name that is unknown, misspelled, or simply newer than the installed build renders nothing at all: no crash, no tofu box, no layout shift, because the row lays out exactly as it would with no icon. That is what lets the vocabulary keep growing without waiting on every shipped app to update.
@expo/vector-icons sits behind a guarded lazy require. Install it and the icons work with no
native rebuild (most Expo apps already ship it). Skip it and the cards render fine without them.
Or skip it and bring your own brand nodes, keyed by the same vocabulary names:
icons={{
instagram: <BrandInstagram />, // override one name with your own mark
"my-custom-thing": <MyGlyph />, // or add a name the AI can emit
}}Icons are decorative: the label carries the meaning, so an icon stays hidden from screen readers and never becomes an option's accessible name.
Backend coupling
The backend (wire-rn/examples/dynamic-onboarding/server) is the source of truth
for what the agent may emit. To make a new card emittable you must, server-side:
- add it to
app/wire.pyKNOWN_COMPONENTS+COMPONENT_DOCS, and - add its name to the tenant's
allowed_components(theregister_*.pyscript),
then fly deploy. Registering the card in this kit alone is not enough: the server validates
every emission against KNOWN_COMPONENTS and the tenant's allowed_components, so a card it does
not know is simply never emitted. (InterstitialCard is already wired on both sides.
CardGridSelectCard is registered here but not yet on the server, so it cannot be emitted
until the two steps above are done.)
Old app versions are protected by a handshake, not by luck: the kit advertises the exact set it
can render (supportedComponents, sent in the reserved A2A metadata), and the server narrows
each turn to that intersection. That is why adding a card here cannot break an app already in the
stores. It never advertises the new name, so the server never sends it one. Do not bypass that
check, because a component name a device cannot render does not degrade gracefully. It throws
COMPONENT_NOT_FOUND inside wireai-rn before the renderer ever sees it, and the user loses the
turn to the retry/fallback path.
Consuming from source (Metro + tsconfig)
The monorepo apps resolve the kit from source. In each app:
tsconfig.json→"paths": { "@wireai/activation": ["<rel>/@wireai/activation/src/index.ts"], "@wireai/activation/*": ["<rel>/@wireai/activation/src/*"] }metro.config.js→ use thewithWireOnboardinghelper withsource(see Installation → source consumers). It watches the kitsrc, maps the@wireai/activationspecifier to it, and pinsreact/react-native/wireai-rn/zodto the app's ownnode_modules(a second React instance crashes RN) — the same wiring the apps spell out by hand.
Consuming apps wire this in one line via withWireOnboarding(getDefaultConfig(__dirname))
in their metro.config.js (npm mode); pass { source } only for local source consumption.
Conventions (for new cards)
Per wire-rn/CONTRIBUTING.md: React.memo + useCallback, the submitted-state
pattern (disable after first interaction), a Zod propsSchema with .describe()
on every field, a description written as an LLM routing instruction, and
StyleSheet.create (no inline styles, no any). tsconfig is strict +
noUncheckedIndexedAccess. Typecheck with npm run typecheck (or through a
consuming app, since the kit has no local TS install).
Coachmarks & guided tours
The kit ships a performance-first guided-tour engine on a subpath so the main
barrel stays dependency-free — import it from @wireai/activation/coachmarks. It
pulls in two optional peers only when you use it: react-native-reanimated
(UI-thread gesture + ring animation) and expo-blur (the frosted spotlight).
Source consumers get it through the existing @wireai/activation/* tsconfig path
mapping; installed apps resolve the subpath export.
The split: the kit owns the animation/blur/ring/measure/queue, the app declares WHERE things anchor (a coachmark id → an on-screen target) and WHICH tour plays. AI-chosen ordering is a later, drop-in phase (see the feature map below) — nothing to rewire.
1. Mount the provider once, around your NavigationContainer. It renders the
overlay host as a root sibling so a ring can paint above the bottom tab bar
(which react-navigation draws over screen components). It takes a synchronous
storage adapter — gate reads must not be async or a ring flashes before the
"already seen" check resolves. An MMKV wrapper is three lines:
import { CoachmarkProvider } from "@wireai/activation/coachmarks";
import { storage } from "./mmkv"; // your MMKV instance
const coachmarkStorage = {
getItem: (k: string) => storage.getString(k) ?? null,
setItem: (k: string, v: string) => storage.set(k, v),
};
<CoachmarkProvider
storage={coachmarkStorage}
accentColor="#3BA9AE" // defaults to the onboarding theme's `primary`
isTestingCoachmark={__DEV__} // QA flag — see below
>
<NavigationContainer>{/* … */}</NavigationContainer>
</CoachmarkProvider>;2. Make any element ringable with useCoachmarkAnchor(id). Attach the ref to
a plain View wrapping the target (collapsable={false} so the native node
survives measurement). Pass null to register nothing (e.g. only the first row
of a list):
import { useCoachmarkAnchor } from "@wireai/activation/coachmarks";
const groupTab = useCoachmarkAnchor("group_tab");
<View ref={groupTab} collapsable={false}>{tabButton}</View>;3. Play an ordered tour with useCoachmarkTour(steps, options). Gating lives
in the kit — it reads/writes wire_coachmark_<tourId>_seen through the injected
storage, so the app keeps only its own domain gates. Steps must be a memoized
array; step callbacks are held in a ref (parent re-renders never re-fire a step):
import { useCoachmarkTour } from "@wireai/activation/coachmarks";
const steps = useMemo(
() => [
{ id: "feed_scroll", message: "Swipe to explore.", gesture: "swipe_up" },
{ id: "group_tab", message: "Your groups live here.", anchorId: "group_tab",
gesture: "tap", onEngage: () => navigation.navigate("Groups") },
],
[navigation],
);
useCoachmarkTour(steps, {
tourId: "home_tour", // gate key + analytics prefix
enabled: hasPosts, // domain gate — arm only when it makes sense
showOnce: true, // default; persisted via the gate
startDelayMs: 3000, // let passive UI land first
onComplete: () => {},
onStepShown: (id) => analytics.track("coachmark_shown", { id }),
onStepEngaged: (id) => analytics.track("coachmark_engaged", { id }),
onStepDismissed: (id) => analytics.track("coachmark_dismissed", { id }),
});Analytics stay callback-based (no analytics dep in the kit). Reduce Motion is honored (the gesture glyph renders static, no motion loop); the blur mounts only while a step is visible and costs nothing at rest; only one overlay is ever active.
Dismissal advances — it doesn't quit. Tapping the ring or tooltip engages the step (fires onStepEngaged) and moves on; tapping the dimmed backdrop advances to the next step (fires onStepDismissed) instead of killing the tour, and only a backdrop tap on the last step ends it. A stray backdrop tap mid-tour never drops the remaining steps.
QA replay — isTestingCoachmark. Set it true on the provider (or call
setCoachmarkTesting(true)) and every seen-gate reads as unseen while every
seen-write is suppressed — so every tour and showcase replays on each mount.
One boolean re-sees the whole surface.
The feature map (declare it now; AI chooses from it later)
Declare your app's coachmarks as a single catalog — the anchor ids, the copy,
and where each lives. Today the whole catalog plays in declared order. Later, the
Wire server emits an ordered coachmarks: string[] id list chosen by the user's
captured intent; you pass it straight to selectTourSteps(catalog, selection) and
nothing else changes — same contract, AI is a drop-in:
import { selectTourSteps } from "@wireai/activation/coachmarks";
// catalog = your feature map; each entry has a stable `id`
const catalog = [
{ id: "feed_scroll", message: "Swipe to explore.", gesture: "swipe_up" },
{ id: "post_location", message: "Tap a place to open it in Maps.", anchorId: "post_location", gesture: "tap" },
{ id: "group_tab", message: "Your groups live here.", anchorId: "group_tab", gesture: "tap" },
];
const steps = selectTourSteps(catalog); // Phase 1-2: full, declared order
const aiSteps = selectTourSteps(catalog, plan.coachmarks); // Phase 3: AI subset + orderselectTourSteps returns the catalog unchanged when selection is absent, and
filters + orders it by the selection ids when present (unknown ids skipped,
duplicates ignored).
Feature showcase (the personalized value bridge)
@wireai/activation/showcase wraps @blazejkustra/react-native-onboarding
(the optional peer) as a small deck of declarative slides. You supply a
ShowcaseConfig; the kit maps it to the package's API, bakes in the onboarding
theme colors, and gates once through the same storage as the tours
(wire_showcase_<id>_seen; isTestingCoachmark bypasses it). If already seen it
renders nothing and calls onDone from an effect.
Recommended placement (v0.3.0+): AFTER onComplete, as a personalized value
bridge. The user just told you what they want in result.answers, so the
terminal recap is the highest-value slot to show the 2-3 slides that answer it,
ordered most-relevant first ("you said you want X, here's how the app does X", the
Duolingo / Headspace pattern). Select them from the answers with
selectShowcaseSlides:
import { FeatureShowcase, selectShowcaseSlides } from "@wireai/activation/showcase";
// The app declares the full slide catalog once (stable ids):
const catalog = [
{ id: "feed", title: "Autoplay feed", description: "Swipe through places.", image: require("./intro/feed.png") },
{ id: "groups", title: "Groups", description: "Plan together.", image: require("./intro/groups.png") },
{ id: "saved", title: "Saved", description: "Everything you liked, in one place.", image: require("./intro/saved.png") },
];
// Sequence: WireOnboarding → onComplete(result) → select from answers → FeatureShowcase → app
<WireOnboarding
onComplete={(result) => {
persist(result.answers);
setSlides(selectShowcaseSlides(catalog, idsFromAnswers(result.answers)));
}}
/>;
<FeatureShowcase
config={{ id: "app_feature_showcase", slides }}
accentColor="#3BA9AE"
onDone={() => goToApp()}
/>;selectShowcaseSlides(catalog, selection?) mirrors selectTourSteps exactly:
returns the catalog unchanged when selection is absent (full deck, declared
order), and filters + orders it by the selection ids when present (unknown ids
skipped, duplicates ignored). idsFromAnswers is the host's own pure map from its
answer keys/values to slide ids. Keep it to 2-3, and fall back to the full deck
when answers are thin.
No gesture props on showcase slides. Swipe/tap hints belong to the coachmark
engine, delivered just-in-time when the user is actually on the screen (the tour
already ships the two-finger glyph). A slide in the value bridge is a still: copy
plus an image (strongly recommended; a slide without one falls back to a
transparent placeholder).
Pre-onboarding placement (the deck before WireOnboarding) is still fully
supported. Placement is host-controlled and the helper is additive; it's just no
longer the recommended shape, because a generic pre-onboarding intro contradicts
the "personalize from context" thesis. The once-gate
(wire_showcase_<id>_seen) works identically in either placement.
It reads the active OnboardingThemeProvider theme; pass theme to override just
the showcase, or storage / isTesting to gate independently of the provider.
v2 server seam (future, not built). The onboarding server's final turn will
later return an ordered showcase: string[] slide-key list chosen server-side from
the same answers; the host passes it straight in as the selection argument.
Same catalog, same contract, AI is a drop-in. The field is additive, so kits that
don't read it simply ignore it (backward-safe).
In-app reviews (sentiment gate)
@wireai/activation/reviews is the drop-in in-app review flow. It asks one neutral
question, then splits honestly on the rating:
- 5 stars route to the native store review. It calls the optional peer
expo-store-review(StoreReview.requestReview()whenisAvailableAsync), and falls back to opening a configurable store URL ({ iosAppId, androidPackage, storeUrl }) when the peer is absent. Firesstore_review_requested. - 1 to 4 stars open a feather-light feedback form (one free-text line plus an optional
contact). The text is POSTed to
POST /v1/reviews. The gate always ends on a warm thank-you, never a dead end.
By default the prompt presents as a centered popup: a card in the middle of the screen
over a dimmed backdrop, with a spring/fade entrance (RN's built-in Animated on the native
driver, no reanimated). A backdrop tap (or Android back) dismisses it, counting as the same
resolve path onResolved already tracks. Pass presentation="inline" for the legacy bare
card that renders wherever you mount it. Everything is theme-aware, and presentation="modal"
is the default so no prop change is needed to adopt it.
Analytics are callback-based, the same convention as the coachmarks. Wire onEvent to your
own analytics; the four moments are review_prompt_shown, review_rating_selected (carries
stars), store_review_requested, and review_feedback_submitted. No PII rides in events.
Feedback text goes only in the POST body.
import { ReviewGate, useReviewGate } from "@wireai/activation/reviews";
// The gate owns WHEN to show. v1 uses the local rules below; pass a server `decision`
// object and it overrides them (the AI seam, see below).
const gate = useReviewGate({
config: { id: "post_onboarding", minSessions: 2, cooldownDays: 90, appVersion: "1.4.0" },
events, // your tracked app-event count, for the minEvents rule
});
return gate.visible ? (
<ReviewGate
appName="Driveline Fit"
store={{ iosAppId: "123456789", androidPackage: "com.driveline.fit" }}
target={{ serverUrl: SERVER_URL, apiKey: TENANT_KEY }}
sessionId={sessionId}
onEvent={(e) => analytics.track(e.name, { id: e.id, stars: e.stars })}
onShown={gate.markShown}
onResolved={gate.markResolved}
/>
) : null;The decision seam (server-controlled firing, AI later)
useReviewGate decides locally today, but the decision is a seam, the same idea as
selectTourSteps. Pass a decision object ({ fire, reason }) and it overrides the local
rules. The Wire server already computes exactly this at GET /v1/reviews/decision, evaluating
per-app trigger rules against the app's event stream, and later a learnings-driven evaluator
replaces the internals behind the same contract. Building the seam now is the whole cost of
being AI-ready.
Use fetchReviewDecision. Do not hand-roll the fetch.
import { fetchReviewDecision, useReviewGate } from "@wireai/activation/reviews";
// The review gate usually lives on the home feed, where there is no onboarding session,
// so deviceKey is the identity that matters. sessionId is optional.
const decision = await fetchReviewDecision(target, { deviceKey });
const gate = useReviewGate({ config, decision: decision ?? undefined, storage });It returns the full { fire, reason, arm } on a 2xx and null on anything else. That
distinction is the whole contract, and it is worth being blunt about why.
A host wrote its own version of this and collapsed an explicit {fire: false} into
undefined. Since the seam is decision ?? local, undefined means "the server has no
opinion, use my local rules", so the server could turn prompts on but never off. The
local timer then asked a real user to rate the app about three minutes into their first
session, before they had seen anything. They left one star.
So: fire: false must reach the gate as false. null must mean only that the server was
unreachable or answered badly, which is the one case where local rules should take over. A 422,
a 500, a 404, or bad JSON all return null and never throw. If the app runs a firing
experiment, echo the returned arm back as meta.firing_arm on the submission so per-arm
attribution survives a later reweighting.
The firing chain, in order: a server decision wins immediately, then after
timeoutFallbackMs the local rules take over (the timeout is a fallback only), and the
once-per-version gate (wire_review_<id>_seen) always applies locally so a server bug can
never spam the prompt. QA replay reuses the coachmark testing flag: isTestingCoachmark
force-shows the gate.
Report generic app events (what the triggers evaluate on)
reportAppEvent(target, "content_share", { sessionId, deviceKey }) reports an arbitrary
in-app event through the existing events transport. The server stores it in the app.
namespace and the review triggers evaluate on it. Keep the name a short stable id and any
meta small and non-PII. This is the seam that lets Wire sit on top of the app's event
stream over time.
Store policy (read before you ship)
Apple's requestReview is quota'd (about 3 per year) and is never guaranteed to show, so
call it only on the honest 5-star path, never from a button labeled "rate us 5 stars". The
sentiment question itself stays neutral ("How's your experience?") with honest options.
Android's in-app review has similar quotas. The kit follows all of this by construction: the
5-star tap is the only path that reaches requestStoreReview.
What is next
The next retention module reuses this exact server-directed shape:
GET /v1/announcements/decision returns which announcement to show per user or session
(AI-targeted later, for example announcing the fix for a feature a user complained about),
with the client once-gating per announcement id through the same storage. Same seam, no code
yet.
Feature controls (per-module kill switches)
Every activation surface has a switch you can flip from the dashboard "in case of something":
coachmarks, showcase, onboarding mode, in-app review, and announcements (reserved). The kit
reads them from GET /v1/features (tenant key, Cache-Control: private, max-age=300) and gates
the three client surfaces accordingly. The server owns the onboarding branch (see "Onboarding
mode" below), so the kit needs no gating there.
The fail-open contract (the whole point). The kit fetches the flags once per session and
caches the last-known value through your storage. If the endpoint is unreachable, returns 401,
returns a 5xx, times out, or sends malformed JSON, the kit reuses its last cached value, and with
no cache it falls back to the all-on defaults (every module on, onboarding ai). A control-plane
outage can only ever be more permissive than the dashboard intends. It can never dark your app.
No failure path ever throws into the host.
Zero setup is a valid setup
Every gated surface works with no flags at all. Omit the wiring and each surface stays on (defaults), so a host that never adopts flags sees zero behavior change and makes zero extra network calls.
One fetch for the whole tree (optional)
Mount WireFeaturesProvider once near your root and every gated surface below it reads the same
resolved flags from context (one fetch serves all modules):
import { WireFeaturesProvider } from "@wireai/activation";
<WireFeaturesProvider config={{ serverUrl, apiKey, appId, storage }}>
<App />
</WireFeaturesProvider>;Resolution order at each surface: an explicit features prop, then the provider context, then a
lazy fetch from a featuresConfig (serverUrl + apiKey) you pass to that surface, then the all-on
defaults. So you can wire flags globally (provider), per surface (featuresConfig), or by handing
already-resolved flags straight in (features). When you consume the built npm subpaths as
separate bundles, prefer the explicit features/featuresConfig props (React context is shared
across subpaths in the source-consumption path, not necessarily across independently-bundled
entries).
Per-surface semantics
- Coachmarks (
CoachmarkProvider):coachmarks.enabled === falsemakes every tour and overlayshow()request a silent no-op. Nothing paints, no error is thrown, and the once-per-tour "seen" gates are not consumed, so re-enabling replays the tours correctly. - Showcase (
FeatureShowcase):showcase.enabled === falserenders nothing and callsonDone()from an effect, exactly as if the showcase had completed. This is the semantics chosen deliberately so a disabled showcase can never strand a navigation flow that waits on a screen which will not appear. It does not write the "seen" gate, so re-enabling replays it. - Reviews (
useReviewGate/ReviewGate):review.enabled === falsemeans the gate never fires (visiblestays false), whatever the local rules or a server decision say. It composes as an extra master switch on top of the existing decision seam.
// Any surface can also take a config directly (lazy fetch), no provider required:
<FeatureShowcase config={showcase} onDone={next} featuresConfig={{ serverUrl, apiKey, appId, storage }} />
const gate = useReviewGate({ config: { id: "post_task" }, features }); // or pass resolved flagsOnboarding mode (server-owned, no kit gating)
onboarding.mode is ai | static | off, and the server owns all three over the same A2A
contract, so the kit needs nothing for it:
ai: the LLM-generated adaptive flow (default).static: the server serves a predefined scripted flow with zero LLM calls, over the same card contract the kit already parses. The kit renders it exactly like the AI flow.off: the server returns a terminal StatusCard on the first turn, which the kit already reads as completion (the user drops into the app in one tap). This folds in the legacy per-tenantonboarding_enabledkill switch.
You can still read onboarding.mode from the resolved flags if you want to branch host UI, but no
gating is required in the kit.
Agent tooling — MCP server + the wire-ai skill
Two extras in this repo help a coding agent (Claude Code, Cursor, Codex) integrate and then improve Wire AI, not just eyeball it.
MCP server (mcp/)
A local Model Context Protocol server (@wireai/mcp, stdio, TypeScript). It is a
repo-local dev tool — not published to npm (excluded from the package files
allowlist). Build it in place:
cd mcp && npm install && npm run buildThen add it to your agent client's MCP config:
{
"mcpServers": {
"wireai": {
"command": "node",
"args": ["<abs-path>/@wireai/activation/mcp/dist/index.js"],
"env": {
"WIREAI_SERVER_URL": "https://<your-backend>.fly.dev",
"WIREAI_API_KEY": "wai_<tenant-key>",
"WIREAI_ADMIN_KEY": "wai_admin_<operator-key>"
}
}
}
}Tools (P1 is read-biased; the only writes are the existing register/update):
| Tool | Auth key | Does |
|---|---|---|
| wireai_get_integration_guide | none | the current integration recipe |
| wireai_list_components | none | the card vocabulary |
| 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 |
| wireai_get_onboarding_config | admin WIREAI_ADMIN_KEY | read the live generation config (context, first question, allowed components) |
| wireai_register_onboarding_app | admin WIREAI_ADMIN_KEY | create an app + get its key |
| wireai_list_onboarding_apps | admin WIREAI_ADMIN_KEY | list apps |
| wireai_get_onboarding_app | admin WIREAI_ADMIN_KEY | one app's raw record |
| wireai_update_onboarding_app | admin WIREAI_ADMIN_KEY | patch config, no release |
| wireai_get_onboarding_analytics | admin WIREAI_ADMIN_KEY | funnel JSON |
| wireai_list_reviews | admin WIREAI_ADMIN_KEY | read in-app reviews across tenants (stars, feedback, contact) |
Least privilege: for the read-only loop, hand the agent just WIREAI_SERVER_URL +
WIREAI_API_KEY (tenant) and skip the admin key. Full detail: mcp/README.md.
wire-ai skill (.claude/skills/wire-ai)
A pure-Markdown Claude skill for using Wire AI easily: install the kit + wire the
WireOnboarding provider, read your insights (via the MCP or /v1/insights.md), and run
the improve loop (read insights, then adjust the app description / first question /
generation context). It also carries the BOILERPLATE20 credit note. For the full
scaffold-the-screen integration job, the wire-rn-integration
skill drives it end to end.
More from Code Meet AI
Open source: wireai-rn · expo_boilerplate · colorway-c-brand · claude_design_skill Products: AI Mobile Launcher · AI Web Launcher · Wire AI · CasaInnov Follow: Newsletter · YouTube
