Skip to main content
ElevenLabs Documentation Docs

Search documentation

Type to search this documentation.

On this pageOverview

Cross-platform Voice Agents with Expo React Native

Cross-platform Voice Agents with Expo React Native

In this tutorial you will learn how to build a voice agent that works across iOS and Android using Expo React Native and the ElevenLabs React Native SDK with WebRTC support.

  • An ElevenLabs account with an API key.
  • Node.js v18 or higher installed on your machine.

Using create-expo-app, create a new blank Expo project:

Bash
npx create-expo-app@latest --template blank-typescript

Install the ElevenLabs React Native SDK and its dependencies:

Bash
npx expo install @elevenlabs/react-native @livekit/react-native @livekit/react-native-webrtc @config-plugins/react-native-webrtc @livekit/react-native-expo-plugin @livekit/react-native-expo-plugin livekit-client

Enable microphone permissions and add Expo plugins

Section titled “Enable microphone permissions and add Expo plugins”

In the app.json file, add the following permissions:

app.json
{
  "expo": {
    "scheme": "elevenlabs",
    // ...
    "ios": {
      "infoPlist": {
        "NSMicrophoneUsageDescription": "This app uses the microphone to record audio."
      },
      "supportsTablet": true,
      "bundleIdentifier": "YOUR.BUNDLE.ID"
    },
    "android": {
      "permissions": [
        "android.permission.RECORD_AUDIO",
        "android.permission.ACCESS_NETWORK_STATE",
        "android.permission.CAMERA",
        "android.permission.INTERNET",
        "android.permission.MODIFY_AUDIO_SETTINGS",
        "android.permission.SYSTEM_ALERT_WINDOW",
        "android.permission.WAKE_LOCK",
        "android.permission.BLUETOOTH"
      ],
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "YOUR.PACKAGE.ID"
    },
    "plugins": ["@livekit/react-native-expo-plugin", "@config-plugins/react-native-webrtc"]
    // ...
  }
}

This will allow the React Native to prompt for microphone permissions when the conversation is started.

Add the ElevenLabs Agents to your app by adding the following code to your ./App.tsx file:

./App.tsx
import { ConversationProvider, useConversation } from "@elevenlabs/react-native";
import type { ConversationStatus, ConversationEvent, Role } from "@elevenlabs/react-native";
import React, { useState } from "react";
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  Keyboard,
  TouchableWithoutFeedback,
  Platform,
} from "react-native";
import { TextInput } from "react-native";

import { getBatteryLevel, changeBrightness, flashScreen } from "./utils/tools";

const ConversationScreen = () => {
  const conversation = useConversation({
    clientTools: {
      getBatteryLevel,
      changeBrightness,
      flashScreen,
    },
    onConnect: ({ conversationId }: { conversationId: string }) => {
      console.log("✅ Connected to conversation", conversationId);
    },
    onDisconnect: (details: string) => {
      console.log("❌ Disconnected from conversation", details);
    },
    onError: (message: string, context?: Record<string, unknown>) => {
      console.error("❌ Conversation error:", message, context);
    },
    onMessage: ({ message, source }: { message: ConversationEvent; source: Role }) => {
      console.log(`💬 Message from ${source}:`, message);
    },
    onModeChange: ({ mode }: { mode: "speaking" | "listening" }) => {
      console.log(`🔊 Mode: ${mode}`);
    },
    onStatusChange: ({ status }: { status: ConversationStatus }) => {
      console.log(`📡 Status: ${status}`);
    },
    onCanSendFeedbackChange: ({ canSendFeedback }: { canSendFeedback: boolean }) => {
      console.log(`🔊 Can send feedback: ${canSendFeedback}`);
    },
  });

  const [isStarting, setIsStarting] = useState(false);
  const [textInput, setTextInput] = useState("");

  const handleSubmitText = () => {
    if (textInput.trim()) {
      conversation.sendUserMessage(textInput.trim());
      setTextInput("");
      Keyboard.dismiss();
    }
  };

  const startConversation = async () => {
    if (isStarting) return;

    setIsStarting(true);
    try {
      await conversation.startSession({
        agentId: process.env.EXPO_PUBLIC_AGENT_ID,
        dynamicVariables: {
          platform: Platform.OS,
        },
      });
    } catch (error) {
      console.error("Failed to start conversation:", error);
    } finally {
      setIsStarting(false);
    }
  };

  const endConversation = async () => {
    try {
      await conversation.endSession();
    } catch (error) {
      console.error("Failed to end conversation:", error);
    }
  };

  const getStatusColor = (status: ConversationStatus): string => {
    switch (status) {
      case "connected":
        return "#10B981";
      case "connecting":
        return "#F59E0B";
      case "disconnected":
        return "#EF4444";
      default:
        return "#6B7280";
    }
  };

  const getStatusText = (status: ConversationStatus): string => {
    return status[0].toUpperCase() + status.slice(1);
  };

  const canStart = conversation.status === "disconnected" && !isStarting;
  const canEnd = conversation.status === "connected";

  return (
    <TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
      <View style={styles.container}>
        <Text style={styles.title}>ElevenLabs React Native Example</Text>
        <Text style={styles.subtitle}>Remember to set the agentId in the .env file!</Text>

        <View style={styles.statusContainer}>
          <View
            style={[styles.statusDot, { backgroundColor: getStatusColor(conversation.status) }]}
          />
          <Text style={styles.statusText}>{getStatusText(conversation.status)}</Text>
        </View>

        {/* Speaking Indicator */}
        {conversation.status === "connected" && (
          <View style={styles.speakingContainer}>
            <View
              style={[
                styles.speakingDot,
                {
                  backgroundColor: conversation.isSpeaking ? "#8B5CF6" : "#D1D5DB",
                },
              ]}
            />
            <Text
              style={[
                styles.speakingText,
                { color: conversation.isSpeaking ? "#8B5CF6" : "#9CA3AF" },
              ]}
            >
              {conversation.isSpeaking ? "🎤 AI Speaking" : "👂 AI Listening"}
            </Text>
          </View>
        )}

        <View style={styles.buttonContainer}>
          <TouchableOpacity
            style={[styles.button, styles.startButton, !canStart && styles.disabledButton]}
            onPress={startConversation}
            disabled={!canStart}
          >
            <Text style={styles.buttonText}>
              {isStarting ? "Starting..." : "Start Conversation"}
            </Text>
          </TouchableOpacity>

          <TouchableOpacity
            style={[styles.button, styles.endButton, !canEnd && styles.disabledButton]}
            onPress={endConversation}
            disabled={!canEnd}
          >
            <Text style={styles.buttonText}>End Conversation</Text>
          </TouchableOpacity>
        </View>

        {/* Feedback Buttons */}
        {conversation.status === "connected" && conversation.canSendFeedback && (
          <View style={styles.feedbackContainer}>
            <Text style={styles.feedbackLabel}>How was that response?</Text>
            <View style={styles.feedbackButtons}>
              <TouchableOpacity
                style={[styles.button, styles.likeButton]}
                onPress={() => conversation.sendFeedback(true)}
              >
                <Text style={styles.buttonText}>👍 Like</Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={[styles.button, styles.dislikeButton]}
                onPress={() => conversation.sendFeedback(false)}
              >
                <Text style={styles.buttonText}>👎 Dislike</Text>
              </TouchableOpacity>
            </View>
          </View>
        )}

        {/* Text Input and Messaging */}
        {conversation.status === "connected" && (
          <View style={styles.messagingContainer}>
            <Text style={styles.messagingLabel}>Send Text Message</Text>
            <TextInput
              style={styles.textInput}
              value={textInput}
              onChangeText={(text) => {
                setTextInput(text);
                // Prevent agent from interrupting while user is typing
                if (text.length > 0) {
                  conversation.sendUserActivity();
                }
              }}
              placeholder="Type your message or context... (Press Enter to send)"
              multiline
              onSubmitEditing={handleSubmitText}
              returnKeyType="send"
              blurOnSubmit={true}
            />
            <View style={styles.messageButtons}>
              <TouchableOpacity
                style={[styles.button, styles.messageButton]}
                onPress={handleSubmitText}
                disabled={!textInput.trim()}
              >
                <Text style={styles.buttonText}>💬 Send Message</Text>
              </TouchableOpacity>
              <TouchableOpacity
                style={[styles.button, styles.contextButton]}
                onPress={() => {
                  if (textInput.trim()) {
                    conversation.sendContextualUpdate(textInput.trim());
                    setTextInput("");
                    Keyboard.dismiss();
                  }
                }}
                disabled={!textInput.trim()}
              >
                <Text style={styles.buttonText}>📝 Send Context</Text>
              </TouchableOpacity>
            </View>
          </View>
        )}
      </View>
    </TouchableWithoutFeedback>
  );
};

export default function App() {
  return (
    <ConversationProvider>
      <ConversationScreen />
    </ConversationProvider>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    backgroundColor: "#F3F4F6",
    padding: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: "bold",
    marginBottom: 8,
    color: "#1F2937",
  },
  subtitle: {
    fontSize: 16,
    color: "#6B7280",
    marginBottom: 32,
  },
  statusContainer: {
    flexDirection: "row",
    alignItems: "center",
    marginBottom: 24,
  },
  statusDot: {
    width: 12,
    height: 12,
    borderRadius: 6,
    marginRight: 8,
  },
  statusText: {
    fontSize: 16,
    fontWeight: "500",
    color: "#374151",
  },
  speakingContainer: {
    flexDirection: "row",
    alignItems: "center",
    marginBottom: 24,
  },
  speakingDot: {
    width: 12,
    height: 12,
    borderRadius: 6,
    marginRight: 8,
  },
  speakingText: {
    fontSize: 14,
    fontWeight: "500",
  },
  toolsContainer: {
    backgroundColor: "#E5E7EB",
    padding: 16,
    borderRadius: 8,
    marginBottom: 24,
    width: "100%",
  },
  toolsTitle: {
    fontSize: 14,
    fontWeight: "600",
    color: "#374151",
    marginBottom: 8,
  },
  toolItem: {
    fontSize: 12,
    color: "#6B7280",
    fontFamily: "monospace",
    marginBottom: 4,
  },
  buttonContainer: {
    width: "100%",
    gap: 16,
  },
  button: {
    backgroundColor: "#3B82F6",
    paddingVertical: 16,
    paddingHorizontal: 32,
    borderRadius: 8,
    alignItems: "center",
  },
  startButton: {
    backgroundColor: "#10B981",
  },
  endButton: {
    backgroundColor: "#EF4444",
  },
  disabledButton: {
    backgroundColor: "#9CA3AF",
  },
  buttonText: {
    color: "white",
    fontSize: 16,
    fontWeight: "600",
  },
  instructions: {
    marginTop: 24,
    fontSize: 14,
    color: "#6B7280",
    textAlign: "center",
    lineHeight: 20,
  },
  feedbackContainer: {
    marginTop: 24,
    alignItems: "center",
  },
  feedbackLabel: {
    fontSize: 16,
    fontWeight: "500",
    color: "#374151",
    marginBottom: 12,
  },
  feedbackButtons: {
    flexDirection: "row",
    gap: 16,
  },
  likeButton: {
    backgroundColor: "#10B981",
  },
  dislikeButton: {
    backgroundColor: "#EF4444",
  },
  messagingContainer: {
    marginTop: 24,
    width: "100%",
  },
  messagingLabel: {
    fontSize: 16,
    fontWeight: "500",
    color: "#374151",
    marginBottom: 8,
  },
  textInput: {
    backgroundColor: "#FFFFFF",
    borderRadius: 8,
    padding: 16,
    minHeight: 100,
    textAlignVertical: "top",
    borderWidth: 1,
    borderColor: "#D1D5DB",
    marginBottom: 16,
  },
  messageButtons: {
    flexDirection: "row",
    gap: 16,
  },
  messageButton: {
    backgroundColor: "#3B82F6",
    flex: 1,
  },
  contextButton: {
    backgroundColor: "#4F46E5",
    flex: 1,
  },
  activityContainer: {
    marginTop: 24,
    alignItems: "center",
  },
  activityLabel: {
    fontSize: 14,
    color: "#6B7280",
    marginBottom: 8,
    textAlign: "center",
  },
  activityButton: {
    backgroundColor: "#F59E0B",
  },
});

A big part of building ElevenLabs agents is allowing the agent access and execute functionality dynamically. This can be done via client tools.

Create a new file to hold your client tools: ./utils/tools.ts and add the following code:

./utils/tools.ts
import * as Battery from "expo-battery";
import * as Brightness from "expo-brightness";

const getBatteryLevel = async () => {
  const batteryLevel = await Battery.getBatteryLevelAsync();
  console.log("batteryLevel", batteryLevel);
  if (batteryLevel === -1) {
    return "Error: Device does not support retrieving the battery level.";
  }
  return batteryLevel;
};

const changeBrightness = ({ brightness }: { brightness: number }) => {
  console.log("changeBrightness", brightness);
  Brightness.setSystemBrightnessAsync(brightness);
  return brightness;
};

const flashScreen = () => {
  Brightness.setSystemBrightnessAsync(1);
  setTimeout(() => {
    Brightness.setSystemBrightnessAsync(0);
  }, 200);
  return "Successfully flashed the screen.";
};

export { getBatteryLevel, changeBrightness, flashScreen };

In addition to the client tools, we’re also injecting the platform (web, iOS, Android) as a dynamic variable both into the first message, and the prompt:

./App.tsx
// ...
const startConversation = async () => {
  if (isStarting) return;

  setIsStarting(true);
  try {
    await conversation.startSession({
      agentId: process.env.EXPO_PUBLIC_AGENT_ID,
      dynamicVariables: {
        platform: Platform.OS,
      },
    });
  } catch (error) {
    console.error("Failed to start conversation:", error);
  } finally {
    setIsStarting(false);
  }
};
// ...
  1. Sign in to ElevenLabs

    Go to elevenlabs.io and sign in to your account.

  2. Create a new agent

    Navigate to Agents Platform > Agents and create a new agent from the blank template.

  3. Set the first message

    Set the first message and specify the dynamic variable for the platform.

    Hi there, woah, so cool that I'm running on {{platform}}. What can I help you with?
  4. Set the system prompt

    Set the system prompt. You can also include dynamic variables here.

    You are a helpful assistant running on {{platform}}. You have access to certain tools that allow you to check the user device battery level and change the display brightness. Use these tools if the user asks about them. Otherwise, just answer the question.
  5. Set up the client tools

    Set up the following client tools:

    • Name: getBatteryLevel
      • Description: Gets the device battery level as decimal point percentage.
      • Wait for response: true
      • Response timeout (seconds): 3
    • Name: changeBrightness
      • Description: Changes the brightness of the device screen.
      • Wait for response: true
      • Response timeout (seconds): 3
      • Parameters:
        • Data Type: number
        • Identifier: brightness
        • Required: true
        • Value Type: LLM Prompt
        • Description: A number between 0 and 1, inclusive, representing the desired screen brightness.
    • Name: flashScreen
      • Description: Quickly flashes the screen on and off.
      • Wait for response: true
      • Response timeout (seconds): 3

This app requires some native dependencies that aren’t supported in Expo Go, therefore you will need to prebuild the app and then run it on a native device.

  • Terminal 1:

    • Run npx expo prebuild --clean
Bash
npx expo prebuild --clean
  • Run npx expo start --tunnel to start the Expo development server over https.
Bash
npx expo start --tunnel
  • Terminal 2:

    • Run npx expo run:ios --device to run the app on your iOS device.
Bash
npx expo run:ios --device
Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu