@thg-agentic/chatbot-sdk-react-native
v0.12.1
Published
React Native SDK for the THG AI Shopping Assistant — exports a single <Chatbot /> component that host apps can mount however they like.
Readme
@thg-agentic/chatbot-sdk-react-native
React Native SDK for the THG AI Shopping Assistant. Exports a single
<Chatbot /> component that you mount wherever makes sense in your
app — full screen, modal, drawer, bottom sheet.
Install
npm install @thg-agentic/chatbot-sdk-react-nativeRequired peer dependencies:
npm install react@>=18 react-native@>=0.72 react-native-svgSSE streaming on React Native: the SDK uses
fetch+ReadableStreamfor AG-UI streaming. Bare RN 0.72+ supports this on iOS, but Android can need a polyfill. Installreact-native-polyfill-globalsand addimport 'react-native-polyfill-globals/auto';to your app entry if you see "response.body is undefined" errors.
Optional peers (per feature)
| Feature | Peer dependency | What you get |
|---|---|---|
| Persistent session/conversation across launches | @react-native-async-storage/async-storage (async) or react-native-mmkv (sync) | Conversation history survives app restart |
| Voice-to-text | @react-native-voice/voice | Mic button + live transcript |
| Image upload | expo-image-picker (preferred) or react-native-image-picker | Attach button + base64 upload |
| iOS Health activity connector | react-native-health | Lets the agent request a consent-gated Apple Health activity summary for fitness-informed Myprotein guidance |
If a peer isn't installed, the corresponding feature is silently hidden — no errors, no setup gymnastics.
iOS Health activity
The Health connector is opt-in at three layers:
- The backend
/v1/configcapability must returnhealthActivity: truefor the channel. - The native app must install and configure
react-native-healthwith the required iOS HealthKit entitlement andNSHealthShareUsageDescription. - The native app must pass a
health.readActivitycallback. The SDK declaresget_health_activityonly when both the backend capability and callback are present.
import { Chatbot } from '@thg-agentic/chatbot-sdk-react-native';
import {
createAppleHealthKitActivityProvider,
requestAppleHealthKitActivityAuthorization,
} from '@thg-agentic/chatbot-sdk-react-native/health/ios';
const health = createAppleHealthKitActivityProvider({
defaultLookbackDays: 14,
includeManuallyAdded: false,
});
// Recommended from a user action, e.g. a "Connect Apple Health" button.
await requestAppleHealthKitActivityAuthorization();
<Chatbot
config={{
channel: 'myprotein',
locale: 'en-GB',
apiUrl: 'https://api.thg.dev/agentic-commerce/shopping-assistant/ag-ui',
apiKey: '<per-storefront-key>',
health,
}}
/>;The adapter asks HealthKit for read access to step count, active energy,
walking/running distance, workouts, and sleep analysis. It returns a bounded
aggregate snapshot rather than raw samples, and partial permission/read failures
are reported in the snapshot so the agent can explain what is missing.
If the host does not preflight authorization, the adapter still calls HealthKit
authorization when the agent first invokes get_health_activity; preflighting
just makes the consent sheet explicit and easier to test.
iOS does not provide a supported deep link to an app's Health data-type permissions. If a user needs to review or change access after the first prompt, guide them to Health app > profile picture > Apps > your app, or Settings > Health > Data Access & Devices > your app.
Use
import { useEffect, useState } from 'react';
import { Chatbot } from '@thg-agentic/chatbot-sdk-react-native';
import type { StorageAdapter } from '@thg-agentic/chatbot-sdk-react-native';
import { createAsyncStorageAdapter } from '@thg-agentic/chatbot-sdk-react-native/storage/async-storage';
export function ChatScreen({ navigation }) {
const [storage, setStorage] = useState<StorageAdapter | null>(null);
useEffect(() => {
const adapter = createAsyncStorageAdapter();
let mounted = true;
adapter.ready().then(() => {
if (mounted) setStorage(adapter);
});
return () => { mounted = false; };
}, []);
if (!storage) return null;
return (
<Chatbot
config={{
channel: 'myprotein',
locale: 'en-GB',
apiUrl: 'https://api.thg.dev/agentic-commerce/shopping-assistant/ag-ui',
apiKey: '<per-storefront-key>',
theme: {
primaryColor: '#003942',
accentColor: '#003942',
},
content: {
headerTitle: 'Fuel Coach',
welcomeMessages: ['Hi! Ask me about anything from our range.'],
},
features: {
showSuggestions: true,
showFeedback: true,
imageUpload: true,
// In production this is overlaid from /v1/config; shown here only
// for local mocks that bypass the config bootstrap.
healthActivity: true,
},
health: createAppleHealthKitActivityProvider(),
}}
storage={storage}
onSettingsPress={() => navigation.navigate('Personalisation')}
onProductPress={(product) => navigation.navigate('PDP', { sku: product.sku })}
onError={(code, message) => console.warn('[Chatbot]', code, message)}
/>
);
}Props
| Prop | Type | Notes |
|---|---|---|
| config | ChatbotConfig | Required. Same shape as the web SDK. |
| storage | StorageAdapter | Optional. Defaults to in-memory (resets on reload). |
| onProductPress | (p: CarouselProduct) => void | Override default product navigation. Without an override, the SDK opens http(s) product URLs with Linking.openURL; relative catalog paths are resolved against config.siteUrl. With one, the SDK records sponsored clicks itself — see Sponsored cards: links, clicks and SKUs. |
| onError | (code, message) => void | Fires for every chat-stream error. |
| onSettingsPress | () => void | Optional. Renders a header control that lets the host open its own memory/connectors/personalisation screen. |
| onMemoryConsentChange | (detail: MemoryConsentChangedDetail) => void | Required (with onSettingsPress) to show the in-chat memory opt-in card — the SDK won't offer an opt-in it can't persist or that the user can't reverse. Fires on accept/decline so the host persists the decision; the SDK reflects it for the session (badge + per-request memory_settings). |
| style | ViewStyle | Applied to the outer container. |
| onActionPress | (actionId: string) => void | Called when the shopper taps a {{action:id\|Label}} placeholder in assistant copy — content asking the host to do something in-app (open support, show settings). Receives the bare id. Separate from onLinkPress, so an action isn't rendered with the external-link affordance. Without a handler the label still renders as inert text, never as raw {{action:…}}. |
| voiceEnabled | boolean | Defaults to true. The host's veto on voice input, which is a different question from whether the platform supports it — pass false when your app ships without the speech permission, or simply doesn't want a mic in its assistant. |
| keyboardBehavior | 'padding' \| 'height' \| 'position' \| null | Defaults to the platform idiom (padding on iOS, height on Android). Override when the default fights your navigation container — Android's height mode can leave the input bar under the keyboard. null disables keyboard avoidance entirely. |
The ChatbotConfig shape covers theme colors, content/i18n strings,
feature flags, analytics callbacks, and session/customer identifiers.
See @thg/chatbot-sdk-core's types.ts for the full interface.
Theming (multi-brand)
The UI is fully theme-driven — there are no brand colours baked into the
components. Every brand (Myprotein, Lookfantastic, Cult Beauty, …) ships the same
SDK and restyles it entirely through config.theme:
| Token | Drives |
|---|---|
| primaryColor | header title + icons, filled CTAs ("Turn on memory", send button when active), suggestion-chip outline, settings toggles |
| primaryTextColor | text/icon colour on primaryColor fills |
| accentColor | the sparkle mark, the active input-pill border, the voice waveform, review stars |
| textColor | body + answer text |
| textMutedColor | disclaimer, placeholder, review counts, struck-through RRP |
| surfaceColor | raised neutral fills — user bubble, input pill, header icon circles, "memory on" badge (defaults to light grey; set it for dark brands) |
| backgroundColor | chat background |
| borderColor | dividers, card outlines |
| errorColor | discount text + (with priceFormat: 'default') the "(N% off)" tag |
| radius | base corner radius |
| sparklesSvg | optional override for the header/suggestions sparkle glyph |
Anything left unset falls back to a neutral default, so a minimal { primaryColor,
accentColor } already produces a coherent look. The example app ships three brand
presets (config.ts) selectable with EXPO_PUBLIC_CHATBOT_BRAND=lookfantastic to
demonstrate that the identical screens restyle per brand.
Internationalisation
i18n works out of the box. All user-facing copy is resolved through
@thg/chatbot-sdk-core's label() against the locale pack chosen by
config.locale (31 built-in languages, including the cross-session memory copy).
Pass locale: 'fr-FR' (etc.) and the header, memory consent card, suggestions,
feedback, errors and thinking states all render translated — no per-brand string
tables required. Host overrides go in config.content (merged on top of the
locale pack), so a brand can sharpen a few strings (e.g. the memory examples)
without losing translations for everything else.
Storage adapters
The SDK uses a small, synchronous StorageAdapter contract:
interface StorageAdapter {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}Three built-in factories:
import { InMemoryStorageAdapter } from '@thg-agentic/chatbot-sdk-react-native';
import { createAsyncStorageAdapter } from '@thg-agentic/chatbot-sdk-react-native/storage/async-storage';
import { createMMKVStorageAdapter } from '@thg-agentic/chatbot-sdk-react-native/storage/mmkv';InMemoryStorageAdapter— default. Conversation resets on reload.createAsyncStorageAdapter()— async backend, sync façade via an in-memory cache pre-warmed at construction. Callawait adapter.ready()before mounting<Chatbot />so the first render reads the persisted session and conversation.createMMKVStorageAdapter()— fully synchronous, recommended if you're already using MMKV in your app.
You can also implement StorageAdapter against your own store
(SecureStore, Keychain, etc.).
Analytics
The SDK reports its own funnel to the backend's first-party /v1/events
endpoint, which lands in the ecommerce_events BigQuery table the Looker
reporting model reads. This is the same transport and the same row shape the web
widget uses (both share AnalyticsClient in @thg/chatbot-sdk-core), so app and
browser sessions are comparable rather than merely adjacent.
Nothing to wire — mounting <Chatbot> starts it. What it emits:
| Event | When |
| --- | --- |
| assistant_open / assistant_close | <Chatbot> mounts / unmounts (close carries duration_ms) |
| assistant_product_click | a product card is tapped |
| product_impression | cards are shown in a settled answer (carries card_position) |
| add_to_cart → add_to_cart_success / add_to_cart_failure | the in-chat add-to-basket lifecycle |
| suggestion_chip_click | a follow-up chip is tapped |
| product_pills_impression / product_pill_click | <ProductPills> renders / is tapped |
| assistant_link_click | an in-message link is tapped |
Rows are stamped event_source: native_ios | native_android (the web widget
stamps web), which is the dimension reporting splits platforms on.
The two things only the host can report
import {
ASSISTANT_EVENTS,
trackAssistantEvent,
trackSiteEvent,
} from '@thg-agentic/chatbot-sdk-react-native';
// 1. Reach — "the shopper could get to the assistant". The entry point is
// yours (a tab, a header button), so the SDK cannot see it. Without this the
// funnel has no top and "opened" has nothing to be a rate against.
trackAssistantEvent(ASSISTANT_EVENTS.IMPRESSION);
// 2. Outcomes in your OWN UI, outside the assistant. Without these the
// assistant's measured contribution stops at the conversation and never
// reaches the conversion it influenced.
trackSiteEvent('add_to_cart', { product_sku: '12345', value: 24.99, currency: 'GBP' });
trackSiteEvent('checkout_started', { value: 62.5, item_count: 3, currency: 'GBP' });Both are safe to call before the assistant mounts: they buffer until something
is mounted to send them. Mount <AnalyticsRoot> once near your app root so that
is always true. Without it, a shopper who never opens the chat or a product page
with pills never sends theirs.
import { AnalyticsRoot } from '@thg-agentic/chatbot-sdk-react-native';
// Same config and storage adapter as <Chatbot>.
<AnalyticsRoot config={config} storage={storage} />Pass it the same storage adapter as <Chatbot>: it posts under the chat's
session, which a <Chatbot> left on its default storage keeps to itself. It
waits for an AsyncStorage adapter's ready() on its own, so unlike <Chatbot>
and <ProductPills> it can mount before the preload finishes.
An add the assistant made is not double-counted when your basket code reports it
back. Include product_sku or variant_sku: the echo is recognised by SKU.
Joining pills to the conversation
Pass <ProductPills storage={...}> the same storage adapter as <Chatbot>.
That is what makes "tapped a pill → opened the assistant → asked something" one
session. Without it pill engagement is still counted, but cannot be joined to
the chat that followed.
Consent and opting out
config.analytics.eventConsentState (a value or a getter) is forwarded on every
batch; the backend decides what to do with it, so consent policy lives in one
place rather than drifting per platform. config.analytics.eventsEnabled: false
switches the transport off entirely.
Retail media (sponsored products)
Sponsored products are blended into the carousels by the backend; the SDK renders them with a "Sponsored" label and reports them to the ad network. It works exactly like the web SDK, driven by the same per-channel settings in the Assistant Console (Retail media section):
| Console setting | What it names |
|---|---|
| Visitor id cookie | the first-party cookie that holds the ad-network visitor id |
| Ad consent cookie + category (+ format) | the CMP cookie, and the category within it that governs advertising |
| Ad consent default | what to do when nothing answers (deny unless set) |
On web the SDK reads those cookies from document.cookie. In an app it reads the
native cookie jar instead — the same store, for the same site
(config.siteUrl), that the app writes — through the optional peer
@react-native-cookies/cookies:
npm install @react-native-cookies/cookies # skip if the app already has itSo an app that already keeps its first-party cookies in the jar writes no retail-media code at all, exactly like a storefront. The jar is re-read on mount, on every return to the foreground, and before every chat turn, so a consent change takes effect on the next turn.
Through the jar alone, consent needs a real signal: the consent cookie must
be present and say yes. The Console default never grants on its own here, and
until the jar has been read once, nothing is granted. The default applies only
when you state consent yourself with getAdConsent — getCookie is just a
cookie source, the same as the jar.
If your cookies live somewhere else (MMKV, AsyncStorage, a session object), point the SDK at them with one bridge instead — it outranks the jar:
const config: ChatbotConfig = {
// Return the CURRENT value of the named cookie, synchronously. The Console
// decides which names are asked for.
getCookie: (name) => myCookieStore[name],
};That is the whole integration. Nothing here is specific to one retailer: an app
is onboarded by naming its own cookies in the Console, on any CMP the parser
supports (onetrust, didomi, cookiebot, token-list, json-path).
Consent is resolved in web's order — your explicit answer, then the
Console-named cookie, then a well-known CMP cookie (OneTrust OptanonConsent /
actualOptanonConsent, Cookiebot CookieConsent), then the Console default —
and it is fail-closed: with no signal, no beacon fires and no visitor id is
sent. Ads still render; they just aren't reported.
Two decisions, kept separate, as on web: firing a beacon is device access (IAB TCF purpose 1), forwarding the visitor id is profiling (purposes 3+4). A shopper can permit the first and refuse the second.
Optional overrides
Only if a cookie can't express it. Both outrank the Console settings.
// Your CMP's answer, stated directly. A bare boolean means both decisions.
getAdConsent: () => ({ storage: true, personalisation: true }),
// A visitor id no cookie holds. Must be the SAME id your other ad surfaces
// send — a per-surface id fragments attribution, which is worse than none.
getRetailerVisitorId: () => visitorId,import { resetSponsoredImpressions } from '@thg-agentic/chatbot-sdk-react-native';
// Optional: start a fresh impression window, e.g. on a new product screen.
resetSponsoredImpressions();setBeaconConsentGate is still exported for apps written against 0.10/0.11.
Once you call it — with a gate, or with null to fail closed — the gate is
yours for the session: the SDK never replaces it, and the cookie jar and
getCookie are no longer read for consent. getAdConsent, if you also provide
it, still governs the visitor id — exactly as in 0.11.
eventSource needs no configuration: the SDK stamps native_ios /
native_android, which is what selects the app's ad placement.
Sponsored cards: links, clicks and SKUs
Clicks are recorded for you. Route a press however your app routes — by SKU to a native screen, as in the example above, or by URL. The SDK reports the click to the ad network on the press (consent-gated, like every beacon); do not fire anything yourself.
url is the product page in an app. The backend sends a native app the
plain product page as a sponsored card's url. A card can still carry the ad
network's click redirect (urlIsClickTracked: true) — on an older backend, or
in a web view — so never put that into an in-app router: it is not a page on
your site. productUrl, when present, is always the plain product page.
sku is the advertised variant. On a sponsored card sku is the exact
shade or size the advertiser bid on, not necessarily the product's master SKU
(on LOOKFANTASTIC, about one sponsored impression in six). Organic cards differ:
there sku is the master and defaultVariantSku the variant. If your product
screen takes only master SKUs, resolve the variant to its product, or route by
productUrl.
What's not in this package
The web SDK has FAB triggers, popup/sidebar layouts, drag/resize, and a
launcher hub for multiple assistants. None of that is here — the host
app owns presentation. If you need a multi-assistant entry point, build
your own screen that decides which assistantId to pass.
Example app
A working Expo example lives at ./example. After
pnpm install at the repo root:
cd packages/chatbot-sdk/react-native/example
pnpm ios # or: pnpm android, pnpm web, pnpm startMetro is set up for the monorepo, so edits to this package (and to
@thg/chatbot-sdk-core) hot-reload without any extra build step. See
example/README.md for backend wiring and notes
on which features run in Expo Go vs. need a dev build.
Local development
This package lives in the THG chatbot-adk monorepo. From the repo root:
pnpm install
pnpm --filter @thg-agentic/chatbot-sdk-react-native typecheck
pnpm --filter @thg-agentic/chatbot-sdk-react-native test
pnpm --filter @thg-agentic/chatbot-sdk-react-native lintThe package's main entry resolves to ./src/index.ts (via the example's Metro
config) so linked monorepo consumers (e.g. the Expo example app under
./example) see live TypeScript edits without a build step.
The web SDK's mock API server at
packages/web/scripts/dev-server.js exposes POST /v1/chat for local
prototyping, but the AG-UI streaming endpoint isn't mocked there — use
a real dev backend or write a lightweight SSE mock for now.
