> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.recursiv.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.recursiv.io/_mcp/server.

# React Native

## 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

```bash
npx expo install @recursiv/sdk
```

Or with npm:

```bash
npm install @recursiv/sdk
```

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

## Basic usage

```typescript
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.

```bash
npx expo install expo-secure-store
```

```typescript
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

> **Warning**
>
> `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

```bash
npm install react-native-sse
```

### Streaming utility

```typescript
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

```typescript
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.

```typescript
// 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

```typescript
// 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.

```typescript
// 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:

```bash
# 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:

```typescript
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:

```typescript
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.

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

## Complete app example

```typescript
// 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');
}
```