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

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

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
1import { Recursiv } from '@recursiv/sdk';
2import * as SecureStore from 'expo-secure-store';
3
4const API_KEY_STORAGE = 'recursiv_api_key';
5const SESSION_TOKEN_STORAGE = 'recursiv_session_token';
6
7// Sign in and store credentials
8async function signIn(email: string, password: string) {
9 // Use a temporary client for auth (auth methods don't need an API key)
10 const authClient = new Recursiv({ apiKey: 'placeholder' });
11
12 const session = await authClient.auth.signIn({ email, password });
13
14 // Store the session token securely
15 await SecureStore.setItemAsync(SESSION_TOKEN_STORAGE, session.token);
16
17 // Create an API key for future SDK operations
18 const apiKey = await authClient.auth.createApiKey(
19 {
20 name: 'Mobile App',
21 scopes: [
22 'posts:read', 'posts:write',
23 'agents:read', 'agents:chat',
24 'chat:read', 'chat:write',
25 'users:read',
26 ],
27 },
28 session.token,
29 );
30
31 // Store the API key securely
32 await SecureStore.setItemAsync(API_KEY_STORAGE, apiKey.key);
33
34 return session;
35}
36
37// Create an authenticated client
38async function getClient(): Promise<Recursiv | null> {
39 const apiKey = await SecureStore.getItemAsync(API_KEY_STORAGE);
40 if (!apiKey) return null;
41 return new Recursiv({ apiKey });
42}
43
44// Sign out
45async function signOut() {
46 const token = await SecureStore.getItemAsync(SESSION_TOKEN_STORAGE);
47 if (token) {
48 const authClient = new Recursiv({ apiKey: 'placeholder' });
49 await authClient.auth.signOut(token);
50 }
51
52 // Also unregister push token if registered
53 const apiKey = await SecureStore.getItemAsync(API_KEY_STORAGE);
54 if (apiKey) {
55 const r = new Recursiv({ apiKey });
56 // Unregister push tokens here if applicable
57 }
58
59 await SecureStore.deleteItemAsync(API_KEY_STORAGE);
60 await SecureStore.deleteItemAsync(SESSION_TOKEN_STORAGE);
61}

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

1import EventSource from 'react-native-sse';
2
3interface StreamChunk {
4 type: 'text_delta' | 'tool_use' | 'tool_result' | 'done' | 'error';
5 delta?: string;
6 tool_name?: string;
7 content?: string;
8 error?: string;
9}
10
11interface StreamCallbacks {
12 onDelta: (text: string) => void;
13 onToolUse?: (toolName: string) => void;
14 onToolResult?: (content: string) => void;
15 onDone: () => void;
16 onError: (error: string) => void;
17}
18
19function streamAgentChat(
20 agentId: string,
21 message: string,
22 apiKey: string,
23 callbacks: StreamCallbacks,
24 conversationId?: string,
25): () => void {
26 const baseUrl = 'https://api.recursiv.io/api/v1';
27 const url = `${baseUrl}/agents/${agentId}/chat/stream`;
28
29 const es = new EventSource(url, {
30 method: 'POST',
31 headers: {
32 Authorization: `Bearer ${apiKey}`,
33 'Content-Type': 'application/json',
34 },
35 body: JSON.stringify({
36 message,
37 ...(conversationId && { conversation_id: conversationId }),
38 }),
39 });
40
41 es.addEventListener('message', (event: any) => {
42 if (!event.data) return;
43
44 if (event.data === '[DONE]') {
45 es.close();
46 callbacks.onDone();
47 return;
48 }
49
50 try {
51 const chunk: StreamChunk = JSON.parse(event.data);
52
53 switch (chunk.type) {
54 case 'text_delta':
55 if (chunk.delta) {
56 callbacks.onDelta(chunk.delta);
57 }
58 break;
59 case 'tool_use':
60 callbacks.onToolUse?.(chunk.tool_name ?? '');
61 break;
62 case 'tool_result':
63 callbacks.onToolResult?.(chunk.content ?? '');
64 break;
65 case 'done':
66 es.close();
67 callbacks.onDone();
68 break;
69 case 'error':
70 callbacks.onError(chunk.error ?? 'Unknown streaming error');
71 es.close();
72 break;
73 }
74 } catch {
75 // Skip malformed JSON chunks
76 }
77 });
78
79 es.addEventListener('error', (event: any) => {
80 callbacks.onError(event.message ?? 'Connection error');
81 es.close();
82 });
83
84 // Return cleanup function
85 return () => {
86 es.close();
87 };
88}

React component

1import React, { useState, useRef, useCallback } from 'react';
2import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from 'react-native';
3import * as SecureStore from 'expo-secure-store';
4
5interface Message {
6 role: 'user' | 'assistant';
7 content: string;
8}
9
10export function AgentChatScreen({ agentId }: { agentId: string }) {
11 const [messages, setMessages] = useState<Message[]>([]);
12 const [input, setInput] = useState('');
13 const [isStreaming, setIsStreaming] = useState(false);
14 const [streamingText, setStreamingText] = useState('');
15 const cleanupRef = useRef<(() => void) | null>(null);
16
17 const sendMessage = useCallback(async () => {
18 if (!input.trim() || isStreaming) return;
19
20 const userMessage = input.trim();
21 setInput('');
22
23 // Add user message to history
24 setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
25 setIsStreaming(true);
26 setStreamingText('');
27
28 const apiKey = await SecureStore.getItemAsync('recursiv_api_key');
29 if (!apiKey) {
30 setMessages((prev) => [
31 ...prev,
32 { role: 'assistant', content: 'Error: No API key found. Please sign in.' },
33 ]);
34 setIsStreaming(false);
35 return;
36 }
37
38 let fullText = '';
39
40 cleanupRef.current = streamAgentChat(
41 agentId,
42 userMessage,
43 apiKey,
44 {
45 onDelta: (delta) => {
46 fullText += delta;
47 setStreamingText(fullText);
48 },
49 onDone: () => {
50 setMessages((prev) => [...prev, { role: 'assistant', content: fullText }]);
51 setStreamingText('');
52 setIsStreaming(false);
53 },
54 onError: (error) => {
55 setMessages((prev) => [
56 ...prev,
57 { role: 'assistant', content: `Error: ${error}` },
58 ]);
59 setStreamingText('');
60 setIsStreaming(false);
61 },
62 },
63 );
64 }, [input, isStreaming, agentId]);
65
66 // Clean up on unmount
67 React.useEffect(() => {
68 return () => {
69 cleanupRef.current?.();
70 };
71 }, []);
72
73 return (
74 <View style={styles.container}>
75 <ScrollView style={styles.messages}>
76 {messages.map((msg, i) => (
77 <View
78 key={i}
79 style={[
80 styles.bubble,
81 msg.role === 'user' ? styles.userBubble : styles.assistantBubble,
82 ]}
83 >
84 <Text style={styles.bubbleText}>{msg.content}</Text>
85 </View>
86 ))}
87 {isStreaming && streamingText ? (
88 <View style={[styles.bubble, styles.assistantBubble]}>
89 <Text style={styles.bubbleText}>{streamingText}</Text>
90 </View>
91 ) : null}
92 </ScrollView>
93 <View style={styles.inputRow}>
94 <TextInput
95 style={styles.input}
96 value={input}
97 onChangeText={setInput}
98 placeholder="Type a message..."
99 editable={!isStreaming}
100 onSubmitEditing={sendMessage}
101 />
102 <TouchableOpacity
103 style={styles.sendButton}
104 onPress={sendMessage}
105 disabled={isStreaming || !input.trim()}
106 >
107 <Text style={styles.sendText}>
108 {isStreaming ? '...' : 'Send'}
109 </Text>
110 </TouchableOpacity>
111 </View>
112 </View>
113 );
114}
115
116const styles = StyleSheet.create({
117 container: { flex: 1, backgroundColor: '#fff' },
118 messages: { flex: 1, padding: 16 },
119 bubble: { padding: 12, borderRadius: 12, marginBottom: 8, maxWidth: '80%' },
120 userBubble: { backgroundColor: '#007AFF', alignSelf: 'flex-end' },
121 assistantBubble: { backgroundColor: '#F0F0F0', alignSelf: 'flex-start' },
122 bubbleText: { fontSize: 16 },
123 inputRow: { flexDirection: 'row', padding: 8, borderTopWidth: 1, borderTopColor: '#E0E0E0' },
124 input: { flex: 1, borderWidth: 1, borderColor: '#E0E0E0', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, fontSize: 16 },
125 sendButton: { marginLeft: 8, backgroundColor: '#007AFF', borderRadius: 20, paddingHorizontal: 16, justifyContent: 'center' },
126 sendText: { color: '#fff', fontWeight: '600' },
127});

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.

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

2. Store keys in SecureStore, not AsyncStorage

1// WRONG -- AsyncStorage is unencrypted
2import AsyncStorage from '@react-native-async-storage/async-storage';
3await AsyncStorage.setItem('api_key', key); // Visible in filesystem!
4
5// CORRECT -- SecureStore uses the platform keychain
6import * as SecureStore from 'expo-secure-store';
7await 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.

1// WRONG -- 'localhost' won't resolve on the device
2const r = new Recursiv({
3 apiKey: 'sk_live_...',
4 baseUrl: 'http://localhost:3000/api/v1',
5});
6
7// CORRECT -- use your machine's IP
8const r = new Recursiv({
9 apiKey: 'sk_live_...',
10 baseUrl: 'http://192.168.1.100:3000/api/v1',
11});

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:

1import { RecursivError } from '@recursiv/sdk';
2
3try {
4 const { data } = await r.posts.list({ limit: 20 });
5} catch (err) {
6 if (err instanceof RecursivError) {
7 // API error (auth, validation, etc.)
8 console.error('API error:', err.message);
9 } else if (err instanceof Error && err.name === 'AbortError') {
10 // Request timeout
11 console.error('Request timed out');
12 } else {
13 // Network error (no connection, DNS failure, etc.)
14 console.error('Network error:', err);
15 }
16}

5. Configure timeout for slow networks

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

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

Expo configuration

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

1// app.json -- no special config needed
2{
3 "expo": {
4 "name": "My App",
5 "slug": "my-app"
6 }
7}

Complete app example

1// app/services/recursiv.ts
2import { Recursiv } from '@recursiv/sdk';
3import * as SecureStore from 'expo-secure-store';
4
5let _client: Recursiv | null = null;
6
7export async function getRecursivClient(): Promise<Recursiv> {
8 if (_client) return _client;
9
10 const apiKey = await SecureStore.getItemAsync('recursiv_api_key');
11 if (!apiKey) {
12 throw new Error('Not authenticated');
13 }
14
15 _client = new Recursiv({ apiKey });
16 return _client;
17}
18
19export function clearClient() {
20 _client = null;
21}
22
23export async function signIn(email: string, password: string) {
24 const tempClient = new Recursiv({ apiKey: 'placeholder' });
25 const session = await tempClient.auth.signIn({ email, password });
26
27 await SecureStore.setItemAsync('session_token', session.token);
28
29 const apiKey = await tempClient.auth.createApiKey(
30 { name: 'Mobile', scopes: ['posts:read', 'agents:chat', 'users:read'] },
31 session.token,
32 );
33
34 await SecureStore.setItemAsync('recursiv_api_key', apiKey.key);
35 _client = new Recursiv({ apiKey: apiKey.key });
36
37 return session;
38}
39
40export async function signOut() {
41 clearClient();
42 await SecureStore.deleteItemAsync('recursiv_api_key');
43 await SecureStore.deleteItemAsync('session_token');
44}