@kailyai/react-native-sdk
v1.1.7
Published
React Native SDK for Kaily AI chatbot with dynamic tool registration and WebView-based integration
Readme
Kaily React Native SDK
A complete React Native SDK for Kaily AI chatbot integration with WebView-based AI functionality, dynamic tool registration, and comprehensive event handling.
Features
- 🚀 Easy Integration - Simple initialization with minimal configuration
- 🛠️ Dynamic Tool Registration - Register custom tools that the AI can execute
- 📱 Cross-Platform - Works on both iOS and Android
- 🎨 Customizable UI - Fully customizable appearance and themes
- 🔊 Voice Support - Built-in voice input and text-to-speech capabilities
- 📎 File Attachments - Native camera, gallery, and document picker with HEIC→JPEG transcode
- 📡 Event System - Comprehensive event handling with EventEmitter
- 🔐 User Management - Built-in user authentication and context management
- 📦 TypeScript Support - Full TypeScript types for enhanced development
Installation
Current Version: 1.1.7
npm install @kailyai/react-native-sdkor
yarn add @kailyai/react-native-sdkNote: The SDK automatically installs its required dependencies (react-native-webview and eventemitter3).
File Attachments — Optional Peer Dependencies
If your copilot is configured to accept user attachments (surfaces.mobile.config.attachments enabled in the admin console), install these peer dependencies so the SDK can open the native camera / gallery / document picker and transcode iOS HEIC photos to JPEG:
npm install react-native-image-picker @react-native-documents/picker @bam.tech/react-native-image-resizerIf you skip this install and attachments are enabled, file pickers will fail at pick-time. The SDK does not enable attachments by itself — the operator controls the toggle server-side and the in-WebView widget hides its attachment button automatically when the toggle is off.
iOS Setup
- Install iOS native dependencies:
Since the SDK uses react-native-webview which requires native modules, you need to install iOS pods:
cd ios
pod install
cd ..- Add permissions to your
ios/YourApp/Info.plist:
<!-- Voice features -->
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone for voice interactions with Kaily AI assistant.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>This app uses speech recognition for voice commands with Kaily AI assistant.</string>
<!-- File attachments (camera + photo library) -->
<key>NSCameraUsageDescription</key>
<string>Used to attach photos to your conversation.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Used to attach images from your library to your conversation.</string>⚠️ All four
NSXxxUsageDescriptionkeys above are mandatory once attachments or voice are enabled server-side. iOS crashes the app withNSInvalidArgumentExceptionon the first call to the matching API — there is no fallback. The "Open Camera" button inside the widget routes throughAVCaptureSession, soNSCameraUsageDescriptionis required even if your host code never touches a camera API directly.
Android Setup
Add the following permissions to your android/app/src/main/AndroidManifest.xml:
<!-- Networking + voice features -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- File attachments (required if attachments are enabled in the admin console) -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Photo library access:
Android 13+ (API 33+): granular media permissions.
Android 14+ (API 34+): optional partial-access permission for "Allow only selected photos".
Android <= 12 (API 32-): legacy READ_EXTERNAL_STORAGE, scoped via maxSdkVersion. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Mark camera as optional so the app stays installable on devices without one -->
<uses-feature android:name="android.hardware.camera" android:required="false" />⚠️ Declare every permission you use. Skip the
CAMERAline andPermissionsAndroid.request(CAMERA)returns"denied"silently — no system dialog appears — and the SDK emitsattachment:errorwithreason: 'permission'. Looks like an SDK bug, but Android's rule is that an undeclared permission can never be granted at runtime. Same trap withRECORD_AUDIOfor the in-widget microphone path.
That's it! The SDK automatically keeps the chat input above the keyboard on both iOS and Android — no additional configuration needed. (See Keyboard avoidance if you ever need to tweak it.)
Quick Start
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, Text } from 'react-native';
import {
KailySDK,
KailyWidget,
KailyTool,
KailyConfig,
} from '@kailyai/react-native-sdk';
export default function App() {
const [isInitialized, setIsInitialized] = useState(false);
const [showChat, setShowChat] = useState(false);
const sdk = KailySDK.getInstance();
useEffect(() => {
initializeSDK();
}, []);
const initializeSDK = async () => {
try {
const config: KailyConfig = {
token: 'your-kaily-token-here',
debugMode: true,
appearance: {
primaryColor: '#007AFF',
title: 'AI Assistant',
},
};
await sdk.initialize(config);
await registerTools();
setIsInitialized(true);
} catch (error) {
console.error('Failed to initialize:', error);
}
};
const registerTools = async () => {
const greetTool = new KailyTool({
name: 'greet_user',
description: 'Greet the user with a personalized message',
parameters: [
{
name: 'name',
type: 'string',
description: 'User name',
required: true,
},
],
handler: async (params) => {
return {
success: true,
data: { message: `Hello, ${params.name}!` },
};
},
});
await sdk.registerTool(greetTool);
};
return (
<View style={{ flex: 1 }}>
<TouchableOpacity onPress={() => setShowChat(!showChat)}>
<Text>Toggle Chat</Text>
</TouchableOpacity>
{showChat && isInitialized && (
<KailyWidget
config={sdk.currentConfig!}
bridge={sdk.getBridge()}
style={{ flex: 1 }}
/>
)}
</View>
);
}Core Concepts
SDK Initialization
The SDK follows a singleton pattern. Initialize once in your app:
const sdk = KailySDK.getInstance();
const config: KailyConfig = {
token: 'your-token',
debugMode: true,
user: {
id: 'user_123',
name: 'John Doe',
email: '[email protected]',
token: 'optional-user-token',
},
context: {
current_page: 'home',
user_tier: 'premium',
},
appearance: {
primaryColor: '#007AFF',
backgroundColor: '#FFFFFF',
title: 'My Assistant',
showHeader: true,
},
voiceConfig: {
enabled: true,
ttsEnabled: true,
language: 'en-US',
},
};
await sdk.initialize(config);Custom Script URL
By default the SDK loads the production Kaily script. To point to a different environment or a self-hosted script, pass kailyUrl in the config:
const config: KailyConfig = {
token: 'your-token',
kailyUrl: '',
};If kailyUrl is not provided, the SDK uses the default production URL.
Tool Registration
Tools allow the AI to execute native functions:
const addToCartTool = new KailyTool({
name: 'add_to_cart',
description: 'Add a product to the shopping cart',
parameters: [
{
name: 'product_id',
type: 'string',
required: true,
},
{
name: 'quantity',
type: 'number',
defaultValue: 1,
},
],
handler: async (params) => {
// Your logic here
return {
success: true,
data: { cartCount: 5 },
};
},
});
// Register single tool
await sdk.registerTool(addToCartTool);
// Register multiple tools
await sdk.registerTools([tool1, tool2, tool3]);
// Unregister tool
await sdk.unregisterTool('add_to_cart');Event Handling
Listen to events from the AI:
const eventStream = sdk.getEventStream();
eventStream.on('event', (event: KailyEvent) => {
console.log('Event:', event.type, event.data);
});
// Listen to specific events
eventStream.on(KailyEventType.ConversationLoaded, (event) => {
console.log('Chat loaded!');
});
eventStream.on(KailyEventType.UserMessage, (event) => {
console.log('User said:', event.data?.message);
});
eventStream.on(KailyEventType.Error, (event) => {
console.error('Error:', event.data?.message);
});User Management
// Set user
await sdk.setUser({
id: 'user_123',
name: 'John Doe',
email: '[email protected]',
token: 'user-auth-token',
attributes: {
tier: 'premium',
},
});
// Unset user
await sdk.unsetUser();
// Notify login success
await sdk.notifyLoginSuccess();
// Logout
await sdk.logout();Context Management
Keep the AI informed about app state:
// Set context
await sdk.setContext({
current_page: 'checkout',
cart_count: 3,
cart_total: 149.99,
user_tier: 'premium',
});
// Update context when state changes
useEffect(() => {
if (sdk.isInitialized()) {
sdk.setContext({
current_page: pageName,
cart_count: cartItems.length,
});
}
}, [cartItems, pageName]);
// Unset context
await sdk.unsetContext();Sending Messages
// Send a message programmatically
await sdk.sendMessage('What products do you recommend?');
// Send custom event
await sdk.sendGenericEvent('custom_event', {
action: 'button_clicked',
value: 'checkout',
});UI Actions
Control the Kaily UI programmatically:
// Toggle the context menu
await sdk.toggleContextMenu();
## API Reference
### KailySDK
Main SDK class (singleton):
```typescript
// Get instance
const sdk = KailySDK.getInstance();
// Initialize
await sdk.initialize(config: KailyConfig): Promise<void>
// Check if initialized
sdk.isInitialized(): boolean
// Tool management
await sdk.registerTool(tool: KailyTool): Promise<void>
await sdk.registerTools(tools: KailyTool[]): Promise<void>
await sdk.unregisterTool(toolName: string): Promise<void>
sdk.getRegisteredTools(): KailyTool[]
await sdk.addFrontendTools(tools: KailyTool[]): Promise<void>
await sdk.removeFrontendTools(toolNames: string[]): Promise<void>
await sdk.removeAllTools(): Promise<void>
// User management
await sdk.setUser(user: KailyUser): Promise<void>
await sdk.unsetUser(): Promise<void>
await sdk.notifyLoginSuccess(): Promise<void>
await sdk.logout(): Promise<void>
// Context management
await sdk.setContext(context: Record<string, any>): Promise<void>
await sdk.unsetContext(): Promise<void>
// Messaging
await sdk.sendMessage(message: string): Promise<void>
await sdk.sendEvent(event: KailyEvent): Promise<void>
await sdk.sendGenericEvent(eventType: string, data: Record<string, any>): Promise<void>
// Event streams
sdk.getEventStream(): EventEmitter
sdk.getToolCallStream(): EventEmitter
// Getters
sdk.currentConfig: KailyConfig | null
sdk.currentUser: KailyUser | null
sdk.currentContext: Record<string, any> | null
sdk.hasUser: boolean
// Lifecycle
sdk.dispose(): void
// UI Actions
await sdk.toggleContextMenu(): Promise<void>KailyWidget
React component for displaying the chat interface:
<KailyWidget
config={config}
bridge={sdk.getBridge()}
onConversationLoaded={() => console.log('Loaded')}
onConversationFailedToLoad={(error) => console.error(error)}
onDeepLinkReceived={(url) => console.log(url)}
onTelemetryEvent={(type, data) => console.log(type, data)}
onClose={() => setShowChat(false)}
onEvent={(event) => console.log(event)}
style={{ flex: 1 }}
/>Keyboard avoidance
You don't need to do anything. By default the SDK keeps the chat input above the
keyboard on both iOS and Android. No AndroidManifest or KeyboardAvoidingView setup.
The optional keyboardAvoidance prop is only for fixing two specific problems:
1. There's an empty gap between the input and the keyboard.
Your app is also moving the screen for the keyboard (a KeyboardAvoidingView wrapper,
or windowSoftInputMode="adjustResize"), so it gets pushed up twice. Tell the SDK to
stand down and let your app handle it:
<KailyWidget config={config} bridge={sdk.getBridge()} style={{ flex: 1 }}
keyboardAvoidance={{ mode: 'none' }}
/>2. The gap above the keyboard is too small / too big.
Tune it. spacing adds space; offset removes it (useful if a tab bar already sits
below the widget, or on Android 15 where the measured keyboard height runs large):
<KailyWidget config={config} bridge={sdk.getBridge()} style={{ flex: 1 }}
keyboardAvoidance={{ spacing: 24, offset: 0 }}
/>That's it. Full option reference:
| Field | Default | What it does |
| --- | --- | --- |
| mode | 'auto' | 'auto' = SDK handles the keyboard. 'none' = SDK does nothing, your app handles it. |
| spacing | 16 | Pixels of breathing room added above the keyboard. |
| offset | 0 | Pixels removed from the keyboard height (for a tab bar / safe-area below the widget). |
⚠️ Upgrading from an older version? The SDK now handles iOS too (it used to be Android-only). If your app already wraps
<KailyWidget>in aKeyboardAvoidingView, you'll see the "empty gap" above — fix it withkeyboardAvoidance={{ mode: 'none' }}.
Configuration Types
KailyConfig
interface KailyConfig {
token: string; // Required
kailyUrl?: string; // Custom script URL
baseUrl?: string;
widgetUrl?: string;
user?: KailyUser;
appearance?: KailyAppearance;
voiceConfig?: KailyVoiceConfig;
debugMode?: boolean;
enableTelemetry?: boolean;
context?: Record<string, any>;
headers?: Record<string, string>;
loadTimeoutMs?: number;
enableJsDebugging?: boolean;
userAgent?: string;
allowExternalNavigation?: boolean;
callbackUrls?: string[];
initParams?: Record<string, any>;
}KeyboardAvoidanceConfig
type KeyboardAvoidanceMode = 'auto' | 'none';
interface KeyboardAvoidanceConfig {
mode?: KeyboardAvoidanceMode; // default 'auto'
spacing?: number; // default 16
offset?: number; // default 0
}KailyUser
interface KailyUser {
id: string; // Required
name?: string;
email?: string;
phone?: string;
avatarUrl?: string;
attributes?: Record<string, any>;
session?: string;
token?: string;
}KailyAppearance
interface KailyAppearance {
primaryColor?: string;
backgroundColor?: string;
textColor?: string;
secondaryTextColor?: string;
userMessageColor?: string;
botMessageColor?: string;
borderRadius?: number;
customCss?: string;
fontFamily?: string;
fontSize?: number;
title?: string;
subtitle?: string;
logoUrl?: string;
showHeader?: boolean;
showTimestamps?: boolean;
darkMode?: boolean;
themeMode?: 'light' | 'dark' | 'auto';
}Event Types
enum KailyEventType {
ConversationLoaded = 'conversationLoaded',
ConversationFailedToLoad = 'conversationFailedToLoad',
DeepLinkReceived = 'deepLinkReceived',
Telemetry = 'telemetry',
Close = 'close',
ToolCall = 'toolCall',
ToolResult = 'toolResult',
UserMessage = 'userMessage',
BotMessage = 'botMessage',
Error = 'error',
AuthChanged = 'authChanged',
ContextUpdated = 'contextUpdated',
VoiceInputStarted = 'voiceInputStarted',
VoiceInputStopped = 'voiceInputStopped',
TtsStarted = 'ttsStarted',
TtsCompleted = 'ttsCompleted',
WidgetResized = 'widgetResized',
Navigation = 'navigation',
Custom = 'custom',
}Error Handling
import {
KailyException,
KailyInitializationException,
KailyConfigurationException,
KailyToolException,
KailyWebViewException,
} from '@kailyai/react-native-sdk';
try {
await sdk.initialize(config);
} catch (error) {
if (error instanceof KailyConfigurationException) {
console.error('Configuration error:', error.field, error.message);
} else if (error instanceof KailyInitializationException) {
console.error('Initialization error:', error.message);
} else {
console.error('Unknown error:', error);
}
}Permissions
Request microphone permission for voice features:
import { PermissionHelper } from '@kailyai/react-native-sdk';
const granted = await PermissionHelper.requestMicrophonePermission();
if (!granted) {
Alert.alert('Permission Required', 'Microphone permission is needed for voice features', [
{ text: 'Cancel' },
{ text: 'Open Settings', onPress: () => PermissionHelper.openAppSettings() },
]);
}File Attachments
When surfaces.mobile.config.attachments is enabled for your copilot, the in-WebView widget renders an attachment button. The SDK transparently bridges those clicks into the host OS's native picker — there is no host-app code to write to wire it up. The contract:
| Concern | Owner |
| --- | --- |
| Tagging requests as the mobile surface | SDK (via WebView URL param + instanceType: 'mobile') |
| Opening the camera / gallery / document picker | SDK (via react-native-image-picker + @react-native-documents/picker) |
| HEIC → JPEG transcode (iOS camera default) | SDK (via @bam.tech/react-native-image-resizer) |
| 10 MB / MIME / extension preflight | SDK + Widget (defense-in-depth) |
| /v1/storage/init and PUT to GCS | In-WebView widget |
| Sending copilot:query { files } over the socket | In-WebView widget |
| Hiding the UI button when the toggle is off | In-WebView widget |
Allowed file types (server-side client-files namespace)
- Images: JPEG, PNG, WebP (HEIC/HEIF auto-transcoded to JPEG on iOS)
- Audio: MP3, WebM
- Documents: PDF
- Max size: 10 MB per file
Observing attachment outcomes
Subscribe to onEvent to react to picker outcomes:
<KailyWidget
config={config}
bridge={bridge}
onEvent={(event) => {
switch (event.type) {
case 'attachment:picked':
console.log('Files attached:', event.data?.names);
break;
case 'attachment:rejected':
console.warn('Rejected:', event.data?.reason, event.data?.name);
break;
case 'attachment:error':
console.warn('Picker error:', event.data?.reason);
break;
}
}}
/>reason values: mime, size, ext, permission, io, unknown. Cancellation is intentionally silent — the user dismissed the picker.
The attachment:picked, attachment:rejected, and attachment:error event payloads also include a target field — one of 'camera' | 'photo-library' | 'files' — so you can show a precise CTA when permission was denied:
onEvent={(event) => {
if (event.type === 'attachment:error' && event.data?.reason === 'permission') {
const target = event.data.target as 'camera' | 'photo-library' | 'files';
Alert.alert(
`Allow ${target === 'camera' ? 'camera' : 'photo'} access`,
'Open Settings to grant permission.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Open Settings', onPress: () => PermissionHelper.openAppSettings() },
]
);
}
}}Permission handling — two paths
Tap "Upload Image", "Upload Files", or "Open Camera" inside the widget and one of two permission flows runs:
| User action | Permission surface | How it's granted |
|---|---|---|
| Upload Image / Upload Files | OS-level CAMERA / photo library via the native picker | SDK calls PermissionHelper.* before launching the picker (host can override via permissionGate) |
| Open Camera (in-widget HTML camera via getUserMedia) | WebView page-level camera + OS-level CAMERA | Android: RNCWebChromeClient auto-grants the page when the host has the OS-level grant. iOS: WKWebView surfaces the system camera dialog (uses NSCameraUsageDescription). |
Default behavior (zero host code)
With the manifest / Info.plist entries above in place, the SDK:
- Calls
PermissionHelper.requestCameraPermission()before launching the native camera. On grant →launchCamera. On deny → emitsattachment:error{reason:'permission', target:'camera'}. - Calls
PermissionHelper.requestPhotoLibraryPermission()before launching the photo library. On Android 13+ this is a no-op —react-native-image-pickeruses the permission-less system photo picker. - For the in-HTML camera button (
getUserMedia): the SDK injects a JS bridge that interceptsnavigator.permissions.query({name:'camera'})and routes it throughPermissionHelper.requestCameraPermission()so the widget sees a real'granted'/'denied'verdict instead of the WebView's default'prompt'dead end. The subsequentgetUserMediacall is granted by the platform's WebView (see table above).
Optional: host-side override (permissionGate)
If you want a branded rationale screen, want to reuse a permission grant from elsewhere in your app, or have compliance constraints, pass a permissionGate prop:
import { KailyWidget, PermissionHelper, type AttachmentPermissionGate } from '@kailyai/react-native-sdk';
const gate: AttachmentPermissionGate = {
// Override the camera path. Photo library still uses the SDK default.
camera: async () => {
const accepted = await showBrandedCameraRationale();
if (!accepted) return false;
return PermissionHelper.requestCameraPermission();
},
};
<KailyWidget config={config} bridge={bridge} permissionGate={gate} />Partial overrides are supported — provide camera, photoLibrary, both, or neither. Missing keys fall through to the SDK default.
Default frontend attachment tools (LLM-invokable)
When you mount KailyWidget, the SDK auto-registers three default frontend tools that the LLM can invoke from chat to trigger the matching native picker:
| Tool name | Effect | Parameters |
| --- | --- | --- |
| open_camera | Launch the device camera | none |
| open_image_library | Open the photo library | multiple?: boolean (default true) |
| open_file_picker | Open the system file picker | accept?: string (default '*/*'), multiple?: boolean (default true) |
Each tool runs the SDK's permission check (same code path as the input-shim picker — permissionGate overrides still apply), opens the matching native picker, and feeds picked files back through the existing upload pipeline (the WebView's __kaily_deliver_files callback). The tool result returned to the LLM is a lightweight ack — { success: true, data: { delivered: true, count: N } } on pick, { success: true, data: { cancelled: true } } on cancel, or { success: false, error: '<reason>' } on permission / IO / validation failure.
Override a default tool via the attachmentTools prop (replaces or merges with the SDK default for the given name):
import { KailyWidget, KailyTool } from '@kailyai/react-native-sdk';
<KailyWidget
config={config}
bridge={bridge}
attachmentTools={{
open_camera: {
description: 'Custom description shown to the LLM',
// handler is optional — omit to keep the SDK default behaviour
handler: async () => {
// your custom flow here
return { success: true, data: { delivered: true, count: 0 } };
},
},
}}
/>If you register your own tool with the same name before mounting the widget, the SDK leaves it alone — the widget never overwrites a host-owned registration. The supported override surface is the attachmentTools prop above.
Permission responsibility for full overrides — if you replace the default handler, the SDK's permission gate is bypassed. Either call PermissionHelper.requestCameraPermission() (or the equivalent) yourself, or set permissionGate on the widget so the SDK still runs the check before any default tool that you haven't fully overridden.
Opt out of default tools entirely via the disableDefaultAttachmentTools prop. When true, the SDK skips auto-registering all three default tools. attachmentTools overrides are also ignored in this mode — the host is responsible for any tool registration via KailySDK.getInstance().registerTool(...).
<KailyWidget
config={config}
bridge={bridge}
disableDefaultAttachmentTools
/>Use this when you want a completely custom attachment tool surface, or when the defaults conflict with your UX. Defaults remain on whenever the prop is unset or false.
Example App
Example App
See the example repository for a comprehensive demo app that showcases:
- SDK initialization with error handling
- Shopping cart with multiple tools
- Event logging
- Context updates
- User management
- Full UI implementation
To run the example, please follow the instructions in the example repository.
Troubleshooting
WebView not loading
- Ensure you have
react-native-webviewinstalled - Check your network connection
- Verify your Kaily token is valid
- Enable
debugMode: trueto see detailed logs
Tools not executing
- Ensure SDK is initialized before registering tools
- Check tool handler returns proper
KailyToolResultformat - Enable debug mode to see tool call logs
- Verify tool names match between registration and AI calls
Events not firing
- Ensure you subscribe to events after SDK initialization
- Check you're listening to the correct event types
- Verify WebView has loaded successfully
Keyboard Overlapping Chat Input
The SDK keeps the input above the keyboard on both iOS and Android by default
(keyboardAvoidance.mode === 'auto') — no AndroidManifest configuration needed. If you
still experience issues:
- Ensure the KailyWidget has a bounded
flex: 1parent —style={{ flex: 1 }}and not nested inside aScrollViewor fixed-height box (the frame must be able to shrink). - Seeing a gap above the keyboard? Your app is double-compensating. Remove your own
KeyboardAvoidingView/windowSoftInputMode=adjustResize, or setkeyboardAvoidance={{ mode: 'none' }}and let your app own it. - On Android 15 /
targetSdkVersion 35(edge-to-edge), the measured keyboard height can include navigation-bar insets — usekeyboardAvoidance={{ offset }}to compensate. - Tune the gap with
keyboardAvoidance={{ spacing }}(default16).
Deep Links Not Working on Android
The SDK automatically handles deep link interception on Android. No additional configuration needed. If links are still redirecting:
- Ensure you're using the latest version of the SDK
- Check that the
onDeepLinkReceivedcallback is set on your KailyWidget - Enable debug mode and check console logs for interception messages
TypeScript Support
This SDK is written in TypeScript and includes full type definitions. No additional @types packages needed.
License
MIT
Support
For issues, questions, or contributions, please visit our GitHub repository.
