React Native

Use the SDK in React Native and Expo apps

Overview

The @recursiv/sdk works in React Native and Expo with zero additional dependencies. It uses the runtime’s native fetch implementation. However, agent streaming (chatStream) does not work in React Native due to missing ReadableStream support. This guide covers installation, basic usage, auth flow, and the complete streaming workaround.

Installation

npx expo install @recursiv/sdk

Or with npm:

npm install @recursiv/sdk

No native modules, no linking, no polyfills needed. The SDK is pure ESM JavaScript with zero dependencies.

Basic usage

import { Recursiv } from '@recursiv/sdk';
const r = new Recursiv({ apiKey: 'sk_live_...' });
// All non-streaming methods work normally
const { data: posts } = await r.posts.list({ limit: 10 });
const { data: me } = await r.users.me();
const { data: reply } = await r.agents.chat('agent_123', {
message: 'Hello!',
});

Auth flow with SecureStore

Use expo-secure-store to store session tokens and API keys securely. Never use AsyncStorage for sensitive data — it stores data unencrypted on disk.

npx expo install expo-secure-store
import { Recursiv } from '@recursiv/sdk';
import * as SecureStore from 'expo-secure-store';
const API_KEY_STORAGE = 'recursiv_api_key';
const SESSION_TOKEN_STORAGE = 'recursiv_session_token';
// Sign in and store credentials
async function signIn(email: string, password: string) {
// Use a temporary client for auth (auth methods don't need an API key)
const authClient = new Recursiv({ apiKey: 'placeholder' });
const session = await authClient.auth.signIn({ email, password });
// Store the session token securely
await SecureStore.setItemAsync(SESSION_TOKEN_STORAGE, session.token);
// Create an API key for future SDK operations
const apiKey = await authClient.auth.createApiKey(
{
name: 'Mobile App',
scopes: [
'posts:read', 'posts:write',
'agents:read', 'agents:write',
'chat:read', 'chat:write',
'users:read',
],
},
session.token,
);
// Store the API key securely
await SecureStore.setItemAsync(API_KEY_STORAGE, apiKey.key);
return session;
}
// Create an authenticated client
async function getClient(): Promise<Recursiv | null> {
const apiKey = await SecureStore.getItemAsync(API_KEY_STORAGE);
if (!apiKey) return null;
return new Recursiv({ apiKey });
}
// Sign out
async function signOut() {
const token = await SecureStore.getItemAsync(SESSION_TOKEN_STORAGE);
if (token) {
const authClient = new Recursiv({ apiKey: 'placeholder' });
await authClient.auth.signOut(token);
}
// Also unregister push token if registered
const apiKey = await SecureStore.getItemAsync(API_KEY_STORAGE);
if (apiKey) {
const r = new Recursiv({ apiKey });
// Unregister push tokens here if applicable
}
await SecureStore.deleteItemAsync(API_KEY_STORAGE);
await SecureStore.deleteItemAsync(SESSION_TOKEN_STORAGE);
}

Agent streaming workaround

r.agents.chatStream() does not work in React Native. React Native’s fetch implementation does not support ReadableStream, which is required for SSE (Server-Sent Events) parsing.

The workaround is to use react-native-sse or manual fetch with line-by-line SSE parsing.

Install react-native-sse

npm install react-native-sse

Streaming utility

import EventSource from 'react-native-sse';
interface StreamChunk {
type: 'text_delta' | 'tool_use' | 'tool_result' | 'done' | 'error';
delta?: string;
tool_name?: string;
content?: string;
error?: string;
}
interface StreamCallbacks {
onDelta: (text: string) => void;
onToolUse?: (toolName: string) => void;
onToolResult?: (content: string) => void;
onDone: () => void;
onError: (error: string) => void;
}
function streamAgentChat(
agentId: string,
message: string,
apiKey: string,
callbacks: StreamCallbacks,
conversationId?: string,
): () => void {
const baseUrl = 'https://api.recursiv.io/api/v1';
const url = `${baseUrl}/agents/${agentId}/chat/stream`;
const es = new EventSource(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
...(conversationId && { conversation_id: conversationId }),
}),
});
es.addEventListener('message', (event: any) => {
if (!event.data) return;
if (event.data === '[DONE]') {
es.close();
callbacks.onDone();
return;
}
try {
const chunk: StreamChunk = JSON.parse(event.data);
switch (chunk.type) {
case 'text_delta':
if (chunk.delta) {
callbacks.onDelta(chunk.delta);
}
break;
case 'tool_use':
callbacks.onToolUse?.(chunk.tool_name ?? '');
break;
case 'tool_result':
callbacks.onToolResult?.(chunk.content ?? '');
break;
case 'done':
es.close();
callbacks.onDone();
break;
case 'error':
callbacks.onError(chunk.error ?? 'Unknown streaming error');
es.close();
break;
}
} catch {
// Skip malformed JSON chunks
}
});
es.addEventListener('error', (event: any) => {
callbacks.onError(event.message ?? 'Connection error');
es.close();
});
// Return cleanup function
return () => {
es.close();
};
}

React component

import React, { useState, useRef, useCallback } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from 'react-native';
import * as SecureStore from 'expo-secure-store';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export function AgentChatScreen({ agentId }: { agentId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState('');
const cleanupRef = useRef<(() => void) | null>(null);
const sendMessage = useCallback(async () => {
if (!input.trim() || isStreaming) return;
const userMessage = input.trim();
setInput('');
// Add user message to history
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
setIsStreaming(true);
setStreamingText('');
const apiKey = await SecureStore.getItemAsync('recursiv_api_key');
if (!apiKey) {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: 'Error: No API key found. Please sign in.' },
]);
setIsStreaming(false);
return;
}
let fullText = '';
cleanupRef.current = streamAgentChat(
agentId,
userMessage,
apiKey,
{
onDelta: (delta) => {
fullText += delta;
setStreamingText(fullText);
},
onDone: () => {
setMessages((prev) => [...prev, { role: 'assistant', content: fullText }]);
setStreamingText('');
setIsStreaming(false);
},
onError: (error) => {
setMessages((prev) => [
...prev,
{ role: 'assistant', content: `Error: ${error}` },
]);
setStreamingText('');
setIsStreaming(false);
},
},
);
}, [input, isStreaming, agentId]);
// Clean up on unmount
React.useEffect(() => {
return () => {
cleanupRef.current?.();
};
}, []);
return (
<View style={styles.container}>
<ScrollView style={styles.messages}>
{messages.map((msg, i) => (
<View
key={i}
style={[
styles.bubble,
msg.role === 'user' ? styles.userBubble : styles.assistantBubble,
]}
>
<Text style={styles.bubbleText}>{msg.content}</Text>
</View>
))}
{isStreaming && streamingText ? (
<View style={[styles.bubble, styles.assistantBubble]}>
<Text style={styles.bubbleText}>{streamingText}</Text>
</View>
) : null}
</ScrollView>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="Type a message..."
editable={!isStreaming}
onSubmitEditing={sendMessage}
/>
<TouchableOpacity
style={styles.sendButton}
onPress={sendMessage}
disabled={isStreaming || !input.trim()}
>
<Text style={styles.sendText}>
{isStreaming ? '...' : 'Send'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
messages: { flex: 1, padding: 16 },
bubble: { padding: 12, borderRadius: 12, marginBottom: 8, maxWidth: '80%' },
userBubble: { backgroundColor: '#007AFF', alignSelf: 'flex-end' },
assistantBubble: { backgroundColor: '#F0F0F0', alignSelf: 'flex-start' },
bubbleText: { fontSize: 16 },
inputRow: { flexDirection: 'row', padding: 8, borderTopWidth: 1, borderTopColor: '#E0E0E0' },
input: { flex: 1, borderWidth: 1, borderColor: '#E0E0E0', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, fontSize: 16 },
sendButton: { marginLeft: 8, backgroundColor: '#007AFF', borderRadius: 20, paddingHorizontal: 16, justifyContent: 'center' },
sendText: { color: '#fff', fontWeight: '600' },
});

Common pitfalls

1. Do not use chatStream()

This is the most common mistake. React Native does not support ReadableStream on fetch responses. Always use the manual SSE approach shown above.

// WRONG -- will crash in React Native
for await (const chunk of r.agents.chatStream(agentId, { message })) {
// ...
}
// CORRECT -- use the manual SSE approach
streamAgentChat(agentId, message, apiKey, callbacks);

2. Store keys in SecureStore, not AsyncStorage

// WRONG -- AsyncStorage is unencrypted
import AsyncStorage from '@react-native-async-storage/async-storage';
await AsyncStorage.setItem('api_key', key); // Visible in filesystem!
// CORRECT -- SecureStore uses the platform keychain
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('api_key', key); // Encrypted

3. Use machine IP for local development

When developing against a local Recursiv instance, use your machine’s IP address instead of localhost. React Native runs on a separate device/simulator that cannot resolve localhost to your development machine.

// WRONG -- 'localhost' won't resolve on the device
const r = new Recursiv({
apiKey: 'sk_live_...',
baseUrl: 'http://localhost:3000/api/v1',
});
// CORRECT -- use your machine's IP
const r = new Recursiv({
apiKey: 'sk_live_...',
baseUrl: 'http://192.168.1.100:3000/api/v1',
});

To find your machine’s IP:

# macOS
ipconfig getifaddr en0
# Linux
hostname -I | awk '{print $1}'

4. Handle network errors gracefully

Mobile networks are unreliable. Always wrap SDK calls in try/catch:

import { RecursivError } from '@recursiv/sdk';
try {
const { data } = await r.posts.list({ limit: 20 });
} catch (err) {
if (err instanceof RecursivError) {
// API error (auth, validation, etc.)
console.error('API error:', err.message);
} else if (err instanceof Error && err.name === 'AbortError') {
// Request timeout
console.error('Request timed out');
} else {
// Network error (no connection, DNS failure, etc.)
console.error('Network error:', err);
}
}

5. Configure timeout for slow networks

Mobile networks can be slow. Consider increasing the default timeout:

const r = new Recursiv({
apiKey: 'sk_live_...',
timeout: 60000, // 60 seconds instead of default 30
maxRetries: 3, // More retries for flaky connections
});

Expo configuration

No special Expo configuration is required. The SDK works with the managed workflow out of the box.

// app.json -- no special config needed
{
"expo": {
"name": "My App",
"slug": "my-app"
}
}

Complete app example

// app/services/recursiv.ts
import { Recursiv } from '@recursiv/sdk';
import * as SecureStore from 'expo-secure-store';
let _client: Recursiv | null = null;
export async function getRecursivClient(): Promise<Recursiv> {
if (_client) return _client;
const apiKey = await SecureStore.getItemAsync('recursiv_api_key');
if (!apiKey) {
throw new Error('Not authenticated');
}
_client = new Recursiv({ apiKey });
return _client;
}
export function clearClient() {
_client = null;
}
export async function signIn(email: string, password: string) {
const tempClient = new Recursiv({ apiKey: 'placeholder' });
const session = await tempClient.auth.signIn({ email, password });
await SecureStore.setItemAsync('session_token', session.token);
const apiKey = await tempClient.auth.createApiKey(
{ name: 'Mobile', scopes: ['posts:read', 'agents:write', 'users:read'] },
session.token,
);
await SecureStore.setItemAsync('recursiv_api_key', apiKey.key);
_client = new Recursiv({ apiKey: apiKey.key });
return session;
}
export async function signOut() {
clearClient();
await SecureStore.deleteItemAsync('recursiv_api_key');
await SecureStore.deleteItemAsync('session_token');
}