Skip to content

React Native Template & Example#226

Merged
tadaspetra merged 11 commits intomainfrom
react-native-example
Apr 14, 2026
Merged

React Native Template & Example#226
tadaspetra merged 11 commits intomainfrom
react-native-example

Conversation

@tadaspetra
Copy link
Copy Markdown
Collaborator

@tadaspetra tadaspetra commented Apr 14, 2026

Note

Medium Risk
Adds a new Expo runtime template and a full-stack Expo web example with server API routes that handle ELEVENLABS_API_KEY, so correctness of routing/env handling impacts example usability but core library logic is unchanged.

Overview
Adds first-class Expo support to the example scaffolding and repo templates, including a new templates/expo/ Expo Router base (server-ready web output, /api/health route, and baseline scripts).

Introduces a new Agents Expo quickstart (agents/expo/quickstart) with a setup.sh that seeds from the Expo template, patches in latest @elevenlabs/react/@elevenlabs/elevenlabs-js, and an example/ app implementing secure API routes (agent+api.ts, conversation-token+api.ts) plus a simple web-first UI that creates/loads agents and starts/stops WebRTC sessions.

Updates the scaffolding skill/docs and scaffold_example.py to recognize expo, preserve .expo on clean, and to auto-pick Next.js examples as the reference fallback when scaffolding new Expo examples; also includes a small status/validation cleanup in the existing Next.js agents quickstart UI.

Reviewed by Cursor Bugbot for commit 530280f. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread agents/expo/quickstart/example/app/index.tsx Outdated
Comment thread agents/expo/quickstart/example/.expo/README.md Outdated
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Redundant API call to fetch just-created agent
    • The Expo POST handler now reuses the known created agent name in its response instead of making an unnecessary follow-up fetch.
Preview (8458fd87ec)
diff --git a/.cursor/skills/scaffold-elevenlabs-example/SKILL.md b/.cursor/skills/scaffold-elevenlabs-example/SKILL.md
--- a/.cursor/skills/scaffold-elevenlabs-example/SKILL.md
+++ b/.cursor/skills/scaffold-elevenlabs-example/SKILL.md
@@ -30,7 +30,7 @@
 
 ```bash
 python3 .cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py \
-  --path text-to-speech/nextjs/my-example
+  --path text-to-speech/expo/my-example

Add --with-assets when the example should ship sample files, or --reference <path> to copy from a specific existing example.
@@ -43,22 +43,24 @@

  • sections are file-by-file using ## \path/to/file``
  • bullets call out concrete SDKs, env handling, models, voice IDs, UI states, and error handling
  • do not restate repo preamble like example/-only rules or DESIGN.md; the generator adds that
    +- for expo, assume the shared template already provides the generic Expo Router shell, server-ready web config, and baseline verification scripts; keep the prompt focused on ElevenLabs-specific UI and +api.ts work
  1. Keep setup.sh aligned with current patterns:
  • use set -euo pipefail
  • derive DIR and REPO_ROOT
    -- clean example/ but preserve cache dirs (node_modules, .venv, .next) when relevant
    +- clean example/ but preserve cache dirs (node_modules, .venv, .next, .expo) when relevant
  • seed from templates/<runtime>/
  • copy README.md into example/README.md
  • copy assets/ and local .env only when present
  • install dependencies at the end
  • for nextjs, fetch latest ElevenLabs package versions at setup time and patch package.json
    +- for expo, keep the shared template generic and server-capable so PROMPT.md only needs to describe the ElevenLabs integration
  1. Keep README.md aligned with the closest current reference:
  • always include a heading, one-sentence summary, ## Setup, and ## Run
    -- add ## Usage for interactive examples such as Next.js and agents demos
    +- add ## Usage for interactive examples such as Next.js, Expo, and agents demos
  • commands should work from inside example/
  1. Recommended when shipping the example: add it to the root README.md.

diff --git a/.cursor/skills/scaffold-elevenlabs-example/reference.md b/.cursor/skills/scaffold-elevenlabs-example/reference.md
--- a/.cursor/skills/scaffold-elevenlabs-example/reference.md
+++ b/.cursor/skills/scaffold-elevenlabs-example/reference.md
@@ -55,12 +55,19 @@

Runtime setup rules

- Runtime Seed template Preserve on clean Env copied into example/ Install step
- typescript templates/typescript/ node_modules .env pnpm install --config.confirmModulesPurge=false
- python templates/python/ .venv .env create .venv, upgrade pip, pip install -r requirements.txt
- nextjs templates/nextjs/ node_modules, .next .env.local patch package.json, then pnpm install --config.confirmModulesPurge=false
+ Runtime Seed template Preserve on clean Env copied into example/ Install step
+ ------------ --------------------- ----------------------- -------------------------- ----------------------------------------------------------------------------
+ typescript templates/typescript/ node_modules .env pnpm install --config.confirmModulesPurge=false
+ python templates/python/ .venv .env create .venv, upgrade pip, pip install -r requirements.txt
+ nextjs templates/nextjs/ node_modules, .next .env.local patch package.json, then pnpm install --config.confirmModulesPurge=false
+ expo templates/expo/ node_modules, .expo .env pnpm install --config.confirmModulesPurge=false

+## Expo runtime notes
+
+- templates/expo/ should provide the generic Expo Router app shell, web.output: "server" config, a baseline /api/health route, and reusable verification scripts such as typecheck and export:web.
+- Keep Expo PROMPT.md files focused on ElevenLabs-specific UI, SDK usage, token exchange, +api.ts routes, and error handling instead of generic app bootstrapping.
+- Until the repo has dedicated Expo examples, use the closest same-product nextjs example as the authoring reference for Expo scaffolds.
+

Prompt rules

  • Start with Before writing any code, invoke the \/skill-name` skill...`.
    @@ -69,11 +76,12 @@
  • Keep prompts short and implementation-focused. Current prompts are direct checklists, not essays.
  • Mention the concrete SDK client, env loading, output format, model ids, voice ids, API route security, and UI behavior when those details are known.
  • Do not repeat repo-wide context that the generator already injects.
    +- For expo, assume the shared template already includes the app shell and baseline checks; only prompt for ElevenLabs-specific changes.

README rules

  • Always include a title, one-sentence summary, ## Setup, and ## Run.
    -- Add ## Usage for interactive or multi-step examples such as Next.js and agents demos.
    +- Add ## Usage for interactive or multi-step examples such as Next.js, Expo, and agents demos.
  • Keep commands valid from inside example/.
  • Use the closest current example as the formatting reference.

@@ -85,6 +93,7 @@

  • CLI transcription or file-based Scribe example: start from the speech-to-text quickstarts.
  • Realtime microphone UI: start from speech-to-text/nextjs/realtime.
  • Voice agent creation and conversation UI: start from agents/nextjs/quickstart.
    +- First Expo full-stack example for a product: start from the closest same-product nextjs example until a dedicated Expo reference exists.
  • For specialized agent behavior, start from agents/nextjs/quickstart and consult agents/nextjs/guardrails only as an existing reference, not as a scaffold mode.

Scaffold helper

@@ -93,7 +102,7 @@

python3 .cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py \
-  --path agents/nextjs/my-agent-demo
+  --path agents/expo/my-agent-demo

Useful flags:

diff --git a/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py b/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py
--- a/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py
+++ b/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py
@@ -18,11 +18,12 @@
parser.add_argument(
"--path",
required=True,

  •    help="Relative path like product/runtime/my-example",
    
  •    help="Relative path like product/runtime/my-example "
    
  •    "(for example, text-to-speech/expo/my-example).",
    
    )
    parser.add_argument(
    "--reference",
  •    help="Explicit reference example path (e.g. music/nextjs/quickstart). "
    
  •    help="Explicit reference example path (e.g. speech-to-text/nextjs/realtime). "
       "Auto-detected from the repo when omitted.",
    
    )
    parser.add_argument(
    @@ -44,7 +45,7 @@
    if len(parts) != 3:
    raise SystemExit(
    "Example paths must look like //, for example "
  •        "text-to-speech/nextjs/my-example."
    
  •        "text-to-speech/expo/my-example."
       )
    

    product, runtime, slug = parts
    @@ -70,23 +71,44 @@
    return results

+def pick_reference(paths: list[Path]) -> Path | None:

  • if not paths:
  •    return None
    
  • return sorted(paths, key=lambda path: (path.name != "quickstart", str(path)))[0]

def find_reference(
product: str, runtime: str, examples: list[tuple[str, str, str, Path]]
) -> Path | None:
"""Pick the best existing example to copy from.

  • Priority: same product+runtime > same runtime > first available.
  • Priority: same product+runtime > same runtime > same product+nextjs for
  • Expo > any nextjs for Expo > first available.
    """
    same_product_runtime = [d for p, r, _, d in examples if p == product and r == runtime]
  • if same_product_runtime:
  •    return same_product_runtime[0]
    
  • match = pick_reference(same_product_runtime)

  • if match:

  •    return match
    

    same_runtime = [d for _, r, _, d in examples if r == runtime]

  • if same_runtime:
  •    return same_runtime[0]
    
  • match = pick_reference(same_runtime)

  • if match:

  •    return match
    
  • if runtime == "expo":

  •    same_product_nextjs = [d for p, r, _, d in examples if p == product and r == "nextjs"]
    
  •    match = pick_reference(same_product_nextjs)
    
  •    if match:
    
  •        return match
    
  •    nextjs_examples = [d for _, r, _, d in examples if r == "nextjs"]
    
  •    match = pick_reference(nextjs_examples)
    
  •    if match:
    
  •        return match
    
  • if examples:

  •    return examples[0][3]
    
  •    return pick_reference([example_dir for _, _, _, example_dir in examples])
    

    return None

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@

  • setup.sh — scaffolds the example/ directory from a shared template
  • example/ — the generated, runnable example with its own README.md

-Shared base templates live in templates/ (Next.js, Python, TypeScript). UI styling rules are in DESIGN.md.
+Shared base templates live in templates/ (Expo, Next.js, Python, TypeScript). UI styling rules are in DESIGN.md.

The legacy examples/ folder is being deprecated and can be ignored for new work.

diff --git a/agents/expo/quickstart/PROMPT.md b/agents/expo/quickstart/PROMPT.md
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/PROMPT.md
@@ -1,0 +1,29 @@
+Before writing any code, invoke the /agents skill to learn the correct ElevenLabs SDK patterns.
+
+## app/api/agent+api.ts
+
+Secure Expo Router API route that creates or loads a voice agent. Never expose ELEVENLABS_API_KEY to the client.
+
+- POST creates a new voice agent with sensible defaults (name, system prompt, first message, TTS voice). Use the CLI voice-only template as reference for the agent shape.
+- GET loads an existing agent by agentId query param.
+- Configure as voice-first: real TTS voice and model, text-only disabled, widget text input disabled.
+- For English agents (language: "en"), use tts.modelId: "eleven_flash_v2". Do not use eleven_flash_v2_5 for English-only agents, or agent creation may fail validation.
+- Enable client events needed for transcript rendering and audio.
+- Return { agentId, agentName }.
+
+## app/api/conversation-token+api.ts
+
+Secure GET endpoint that returns a signed WebSocket URL for a given agentId using getSignedUrl.
+Never expose ELEVENLABS_API_KEY to the client. Return { signedUrl } as JSON.
+Do NOT use getWebrtcToken — WebRTC does not work reliably in the Expo web runtime.
+
+## app/index.tsx
+
+Minimal Expo Router voice agent screen.
+
+- Use @elevenlabs/react and the useConversation hook for the web experience.
+- Show a Create Agent button and an editable agent-id input. Auto-populate on create; allow pasting a different id to load it instead.
+- Start sessions with a signed URL from /api/conversation-token using startSession({ signedUrl }). Do NOT pass connectionType: "webrtc". Request mic access before starting.
+- Show a Start/Stop toggle, connection status, and running conversation transcript (append messages, don't replace).
+- Handle errors gracefully and allow reconnect. Keep the UI simple and voice-first.
+- Keep the verified path web-first: use relative fetch calls for Expo web, and render a brief native fallback note instead of attempting an unsupported in-app server flow.

diff --git a/agents/expo/quickstart/README.md b/agents/expo/quickstart/README.md
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/README.md
@@ -1,0 +1,39 @@
+# Real-Time Voice Agent (Expo)
+
+Live voice conversations with the ElevenLabs Agents Platform in an Expo Router app with secure Expo API routes for web.
+
+## Setup
+
+1. Copy the environment file and add your credentials:
+

  • cp .env.example .env
  • Then edit .env and set:
    • ELEVENLABS_API_KEY

+2. Install dependencies:
+

  • pnpm install

+## Run
+
+bash +pnpm run web +
+
+Open the local Expo web URL shown in the terminal.
+
+## Usage
+
+- Enter an agent name and a system prompt, then click Create agent.
+- The app creates the agent server-side and stores the returned agent id in the page.
+- Click Start and allow microphone access when prompted.
+- The app fetches a fresh conversation token for the created agent and starts a WebRTC session.
+- Speak naturally and watch the live conversation state update as the agent listens and responds.
+- The page shows whether the agent is currently speaking and renders the interaction as a running conversation.
+- Click Stop to end the session.
+- This quickstart is verified for Expo web. Native builds need a deployed Expo server origin before the in-app client can call the secure API routes.

diff --git a/agents/expo/quickstart/example/.env.example b/agents/expo/quickstart/example/.env.example
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/.env.example
@@ -1,0 +1 @@
+ELEVENLABS_API_KEY=

diff --git a/agents/expo/quickstart/example/.gitignore b/agents/expo/quickstart/example/.gitignore
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/.gitignore
@@ -1,0 +1,7 @@
+
+# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
+# The following patterns were generated by expo-cli
+
+.expo/
+expo-env.d.ts
+# @EnD expo-cli
\ No newline at end of file

diff --git a/agents/expo/quickstart/example/README.md b/agents/expo/quickstart/example/README.md
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/README.md
@@ -1,0 +1,39 @@
+# Real-Time Voice Agent (Expo)
+
+Live voice conversations with the ElevenLabs Agents Platform in an Expo Router app with secure Expo API routes for web.
+
+## Setup
+
+1. Copy the environment file and add your credentials:
+

  • cp .env.example .env
  • Then edit .env and set:
    • ELEVENLABS_API_KEY

+2. Install dependencies:
+

  • pnpm install

+## Run
+
+bash +pnpm run web +
+
+Open the local Expo web URL shown in the terminal.
+
+## Usage
+
+- Enter an agent name and a system prompt, then click Create agent.
+- The app creates the agent server-side and stores the returned agent id in the page.
+- Click Start and allow microphone access when prompted.
+- The app fetches a fresh conversation token for the created agent and starts a WebRTC session.
+- Speak naturally and watch the live conversation state update as the agent listens and responds.
+- The page shows whether the agent is currently speaking and renders the interaction as a running conversation.
+- Click Stop to end the session.
+- This quickstart is verified for Expo web. Native builds need a deployed Expo server origin before the in-app client can call the secure API routes.

diff --git a/agents/expo/quickstart/example/app.json b/agents/expo/quickstart/example/app.json
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app.json
@@ -1,0 +1,16 @@
+{

  • "expo": {
  • "name": "Real-Time Voice Agent",
  • "slug": "realtime-voice-agent-expo",
  • "scheme": "realtime-voice-agent-expo",
  • "version": "1.0.0",
  • "orientation": "portrait",
  • "web": {
  •  "output": "server"
    
  • },
  • "plugins": ["expo-router"],
  • "experiments": {
  •  "typedRoutes": true
    
  • }
  • }
    +}

diff --git a/agents/expo/quickstart/example/app/_layout.tsx b/agents/expo/quickstart/example/app/_layout.tsx
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/_layout.tsx
@@ -1,0 +1,12 @@
+import { Stack } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+
+export default function RootLayout() {

  • return (
  •  <StatusBar style="dark" />
    
  •  <Stack screenOptions={{ headerShown: false }} />
    
  • );
    +}

diff --git a/agents/expo/quickstart/example/app/api/agent+api.ts b/agents/expo/quickstart/example/app/api/agent+api.ts
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/api/agent+api.ts
@@ -1,0 +1,103 @@
+import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
+import { ClientEvent } from "@elevenlabs/elevenlabs-js/api/types/ClientEvent";
+import type { ConversationalConfig } from "@elevenlabs/elevenlabs-js/api/types/ConversationalConfig";
+
+function getClient() {

  • const apiKey = process.env.ELEVENLABS_API_KEY;
  • if (!apiKey) {
  • return {
  •  error: Response.json(
    
  •    { error: "Missing ELEVENLABS_API_KEY" },
    
  •    { status: 500 }
    
  •  ),
    
  • };
  • }
  • return { client: new ElevenLabsClient({ apiKey }) };
    +}

+function voiceFirstConversationConfig(): ConversationalConfig {

  • return {
  • agent: {
  •  firstMessage: "Hello! How can I help you today?",
    
  •  language: "en",
    
  •  prompt: {
    
  •    prompt:
    
  •      "You are a helpful voice assistant. Keep replies concise and natural for spoken conversation.",
    
  •    llm: "gemini-2.0-flash",
    
  •    temperature: 0.7,
    
  •  },
    
  • },
  • tts: {
  •  voiceId: "JBFqnCBsd6RMkjVDRZzb",
    
  •  modelId: "eleven_flash_v2",
    
  • },
  • conversation: {
  •  textOnly: false,
    
  •  clientEvents: [
    
  •    ClientEvent.UserTranscript,
    
  •    ClientEvent.TentativeUserTranscript,
    
  •    ClientEvent.AgentResponse,
    
  •    ClientEvent.AgentChatResponsePart,
    
  •    ClientEvent.Audio,
    
  •  ],
    
  • },
  • };
    +}

+export async function POST() {

  • const res = getClient();
  • if ("error" in res) {
  • return res.error;
  • }
  • try {
  • const agentName = "Expo Voice Agent";
  • const created = await res.client.conversationalAi.agents.create({
  •  name: agentName,
    
  •  enableVersioning: true,
    
  •  conversationConfig: voiceFirstConversationConfig(),
    
  •  platformSettings: {
    
  •    widget: {
    
  •      textInputEnabled: false,
    
  •      supportsTextOnly: false,
    
  •      conversationModeToggleEnabled: false,
    
  •    },
    
  •  },
    
  • });
  • return Response.json({
  •  agentId: created.agentId,
    
  •  agentName,
    
  • });
  • } catch (e) {
  • const message = e instanceof Error ? e.message : "Failed to create agent";
  • return Response.json({ error: message }, { status: 500 });
  • }
    +}

+export async function GET(request: Request) {

  • const res = getClient();
  • if ("error" in res) {
  • return res.error;
  • }
  • const url = new URL(request.url);
  • const agentId = url.searchParams.get("agentId");
  • if (!agentId?.trim()) {
  • return Response.json(
  •  { error: "Missing agentId query parameter" },
    
  •  { status: 400 }
    
  • );
  • }
  • try {
  • const agent = await res.client.conversationalAi.agents.get(agentId.trim());
  • return Response.json({
  •  agentId: agent.agentId,
    
  •  agentName: agent.name,
    
  • });
  • } catch (e) {
  • const message = e instanceof Error ? e.message : "Failed to load agent";
  • return Response.json({ error: message }, { status: 500 });
  • }
    +}

diff --git a/agents/expo/quickstart/example/app/api/conversation-token+api.ts b/agents/expo/quickstart/example/app/api/conversation-token+api.ts
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/api/conversation-token+api.ts
@@ -1,0 +1,31 @@
+import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
+
+export async function GET(request: Request) {

  • const apiKey = process.env.ELEVENLABS_API_KEY;
  • if (!apiKey) {
  • return Response.json(
  •  { error: "Missing ELEVENLABS_API_KEY" },
    
  •  { status: 500 }
    
  • );
  • }
  • const url = new URL(request.url);
  • const agentId = url.searchParams.get("agentId");
  • if (!agentId?.trim()) {
  • return Response.json(
  •  { error: "Missing agentId query parameter" },
    
  •  { status: 400 }
    
  • );
  • }
  • try {
  • const client = new ElevenLabsClient({ apiKey });
  • const signed = await client.conversationalAi.conversations.getSignedUrl({
  •  agentId: agentId.trim(),
    
  • });
  • return Response.json({ signedUrl: signed.signedUrl });
  • } catch (e) {
  • const message = e instanceof Error ? e.message : "Failed to get signed URL";
  • return Response.json({ error: message }, { status: 500 });
  • }
    +}

diff --git a/agents/expo/quickstart/example/app/api/health+api.ts b/agents/expo/quickstart/example/app/api/health+api.ts
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/api/health+api.ts
@@ -1,0 +1,6 @@
+export function GET() {

  • return Response.json({
  • ok: true,
  • runtime: "expo-router-api",
  • });
    +}

diff --git a/agents/expo/quickstart/example/app/index.tsx b/agents/expo/quickstart/example/app/index.tsx
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/index.tsx
@@ -1,0 +1,438 @@
+import { useCallback, useState } from "react";
+import {

  • ActivityIndicator,
  • Platform,
  • Pressable,
  • ScrollView,
  • StyleSheet,
  • Text,
  • TextInput,
  • View,
    +} from "react-native";
    +import { SafeAreaView } from "react-native-safe-area-context";
    +import { ConversationProvider, useConversation } from "@elevenlabs/react";

+type TranscriptLine = {

  • key: string;
  • role: "user" | "agent";
  • text: string;
    +};

+function VoiceAgentPanel() {

  • const [agentId, setAgentId] = useState("");
  • const [agentName, setAgentName] = useState<string | null>(null);
  • const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
  • const [apiError, setApiError] = useState<string | null>(null);
  • const [busy, setBusy] = useState<"idle" | "create" | "load" | "token">(
  • "idle"
  • );
  • const {
  • startSession,
  • endSession,
  • status,
  • message: statusMessage,
  • } = useConversation({
  • onMessage: props => {
  •  setTranscript(prev => [
    
  •    ...prev,
    
  •    {
    
  •      key: `${props.event_id ?? Date.now()}-${prev.length}`,
    
  •      role: props.role,
    
  •      text: props.message,
    
  •    },
    
  •  ]);
    
  • },
  • });
  • const clearConversationError = useCallback(() => {
  • setApiError(null);
  • }, []);
  • const createAgent = useCallback(async () => {
  • setBusy("create");
  • setApiError(null);
  • try {
  •  const response = await fetch("/api/agent", { method: "POST" });
    
  •  const data = (await response.json()) as {
    
  •    agentId?: string;
    
  •    agentName?: string;
    
  •    error?: string;
    
  •  };
    
  •  if (!response.ok) {
    
  •    throw new Error(data.error ?? `Request failed (${response.status})`);
    
  •  }
    
  •  if (!data.agentId) {
    
  •    throw new Error("Missing agentId in response");
    
  •  }
    
  •  setAgentId(data.agentId);
    
  •  setAgentName(data.agentName ?? null);
    
  • } catch (e) {
  •  setApiError(e instanceof Error ? e.message : "Failed to create agent");
    
  • } finally {
  •  setBusy("idle");
    
  • }
  • }, []);
  • const loadAgent = useCallback(async () => {
  • if (!agentId.trim()) {
  •  setApiError("Enter an agent id first.");
    
  •  return;
    
  • }
  • setBusy("load");
  • setApiError(null);
  • try {
  •  const response = await fetch(
    
  •    `/api/agent?agentId=${encodeURIComponent(agentId.trim())}`
    
  •  );
    
  •  const data = (await response.json()) as {
    
  •    agentId?: string;
    
  •    agentName?: string;
    
  •    error?: string;
    
  •  };
    
  •  if (!response.ok) {
    
  •    throw new Error(data.error ?? `Request failed (${response.status})`);
    
  •  }
    
  •  setAgentName(data.agentName ?? null);
    
  • } catch (e) {
  •  setApiError(e instanceof Error ? e.message : "Failed to load agent");
    
  • } finally {
  •  setBusy("idle");
    
  • }
  • }, [agentId]);
  • const startVoice = useCallback(async () => {
  • if (!agentId.trim()) {
  •  setApiError("Enter or create an agent id first.");
    
  •  return;
    
  • }
  • setBusy("token");
  • setApiError(null);
  • setTranscript([]);
  • try {
  •  if (
    
  •    typeof navigator !== "undefined" &&
    
  •    navigator.mediaDevices?.getUserMedia
    
  •  ) {
    
  •    await navigator.mediaDevices.getUserMedia({ audio: true });
    
  •  }
    
  •  const response = await fetch(
    
  •    `/api/conversation-token?agentId=${encodeURIComponent(agentId.trim())}`
    
  •  );
    
  •  const data = (await response.json()) as {
    
  •    signedUrl?: string;
    
  •    error?: string;
    
  •  };
    
  •  if (!response.ok) {
    
  •    throw new Error(data.error ?? `Request failed (${response.status})`);
    
  •  }
    
  •  if (!data.signedUrl) {
    
  •    throw new Error("Missing signedUrl in response");
    
  •  }
    
  •  await startSession({ signedUrl: data.signedUrl });
    
  • } catch (e) {
  •  setApiError(e instanceof Error ? e.message : "Failed to start session");
    
  • } finally {
  •  setBusy("idle");
    
  • }
  • }, [agentId, startSession]);
  • const stopVoice = useCallback(() => {
  • setApiError(null);
  • void endSession();
  • }, [endSession]);
  • const isBusy = busy !== "idle";
  • const isConnected = status === "connected";
  • const canStart = !isBusy && !isConnected && !!agentId.trim();
  • const primaryDisabled = isConnected ? false : !canStart || isBusy;
  • return (
  •  <Text style={styles.label}>Agent ID</Text>
    
  •  <TextInput
    
  •    autoCapitalize="none"
    
  •    autoCorrect={false}
    
  •    onChangeText={t => {
    
  •      setAgentId(t);
    
  •      clearConversationError();
    
  •    }}
    
  •    placeholder="Paste or create an agent id"
    
  •    style={styles.input}
    
  •    value={agentId}
    
  •  />
    
  •  <View style={styles.row}>
    
  •    <Pressable
    
  •      accessibilityRole="button"
    
  •      disabled={isBusy}
    
  •      onPress={createAgent}
    
  •      style={({ pressed }) => [
    
  •        styles.buttonSecondary,
    
  •        (pressed || isBusy) && styles.buttonPressed,
    
  •      ]}
    
  •    >
    
  •      {busy === "create" ? (
    
  •        <ActivityIndicator color="#171717" />
    
  •      ) : (
    
  •        <Text style={styles.buttonSecondaryText}>Create Agent</Text>
    
  •      )}
    
  •    </Pressable>
    
  •    <Pressable
    
  •      accessibilityRole="button"
    
  •      disabled={isBusy}
    
  •      onPress={loadAgent}
    
  •      style={({ pressed }) => [
    
  •        styles.buttonSecondary,
    
  •        (pressed || isBusy) && styles.buttonPressed,
    
  •      ]}
    
  •    >
    
  •      {busy === "load" ? (
    
  •        <ActivityIndicator color="#171717" />
    
  •      ) : (
    
  •        <Text style={styles.buttonSecondaryText}>Load agent</Text>
    
  •      )}
    
  •    </Pressable>
    
  •  </View>
    
  •  {agentName ? <Text style={styles.meta}>Loaded: {agentName}</Text> : null}
    
  •  <Pressable
    
  •    accessibilityRole="button"
    
  •    disabled={primaryDisabled}
    
  •    onPress={isConnected ? stopVoice : startVoice}
    
  •    style={({ pressed }) => [
    
  •      styles.buttonPrimary,
    
  •      (pressed || (isBusy && busy === "token")) && styles.buttonPressed,
    
  •      primaryDisabled ? styles.buttonDisabled : null,
    
  •    ]}
    
  •  >
    
  •    {busy === "token" ? (
    
  •      <ActivityIndicator color="#ffffff" />
    
  •    ) : (
    
  •      <Text style={styles.buttonText}>
    
  •        {isConnected ? "Stop" : "Start"}
    
  •      </Text>
    
  •    )}
    
  •  </Pressable>
    
  •  <Text style={styles.statusLabel}>Status</Text>
    
  •  <Text style={styles.status}>
    
  •    {status}
    
  •    {statusMessage ? ` — ${statusMessage}` : ""}
    
  •  </Text>
    
  •  {apiError ? <Text style={styles.error}>{apiError}</Text> : null}
    
  •  <Text style={styles.transcriptLabel}>Transcript</Text>
    
  •  <ScrollView style={styles.transcriptBox}>
    
  •    {transcript.length === 0 ? (
    
  •      <Text style={styles.transcriptEmpty}>
    
  •        Messages appear here during a conversation.
    
  •      </Text>
    
  •    ) : (
    
  •      transcript.map(line => (
    
  •        <Text key={line.key} style={styles.transcriptLine}>
    
  •          <Text style={styles.transcriptRole}>
    
  •            {line.role === "user" ? "You" : "Agent"}:{" "}
    
  •          </Text>
    
  •          {line.text}
    
  •        </Text>
    
  •      ))
    
  •    )}
    
  •  </ScrollView>
    
  • );
    +}

+export default function HomeScreen() {

  • const [providerError, setProviderError] = useState<string | null>(null);
  • if (Platform.OS !== "web") {
  • return (
  •  <SafeAreaView style={styles.screen}>
    
  •    <View style={styles.container}>
    
  •      <Text style={styles.eyebrow}>Expo Template</Text>
    
  •      <Text style={styles.title}>Voice agent (web)</Text>
    
  •      <Text style={styles.description}>
    
  •        This example uses Expo Router API routes and the ElevenLabs web
    
  •        conversation client. Run the app with{" "}
    

... diff truncated: showing 800 of 1452 lines


</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-30654c84-e3dc-4d23-b72a-5aaf76e4208d">here</a>.</sub>

</details>

Comment thread agents/expo/quickstart/example/app/api/agent+api.ts Outdated
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Nested container style causes double padding on form
    • Removed the inner styles.container wrapper from VoiceAgentPanel so the form now shares the parent container padding and aligns with the header.
Preview (ecb9293333)
diff --git a/.cursor/skills/scaffold-elevenlabs-example/SKILL.md b/.cursor/skills/scaffold-elevenlabs-example/SKILL.md
--- a/.cursor/skills/scaffold-elevenlabs-example/SKILL.md
+++ b/.cursor/skills/scaffold-elevenlabs-example/SKILL.md
@@ -30,7 +30,7 @@
 
 ```bash
 python3 .cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py \
-  --path text-to-speech/nextjs/my-example
+  --path text-to-speech/expo/my-example

Add --with-assets when the example should ship sample files, or --reference <path> to copy from a specific existing example.
@@ -43,22 +43,24 @@

  • sections are file-by-file using ## \path/to/file``
  • bullets call out concrete SDKs, env handling, models, voice IDs, UI states, and error handling
  • do not restate repo preamble like example/-only rules or DESIGN.md; the generator adds that
    +- for expo, assume the shared template already provides the generic Expo Router shell, server-ready web config, and baseline verification scripts; keep the prompt focused on ElevenLabs-specific UI and +api.ts work
  1. Keep setup.sh aligned with current patterns:
  • use set -euo pipefail
  • derive DIR and REPO_ROOT
    -- clean example/ but preserve cache dirs (node_modules, .venv, .next) when relevant
    +- clean example/ but preserve cache dirs (node_modules, .venv, .next, .expo) when relevant
  • seed from templates/<runtime>/
  • copy README.md into example/README.md
  • copy assets/ and local .env only when present
  • install dependencies at the end
  • for nextjs, fetch latest ElevenLabs package versions at setup time and patch package.json
    +- for expo, keep the shared template generic and server-capable so PROMPT.md only needs to describe the ElevenLabs integration
  1. Keep README.md aligned with the closest current reference:
  • always include a heading, one-sentence summary, ## Setup, and ## Run
    -- add ## Usage for interactive examples such as Next.js and agents demos
    +- add ## Usage for interactive examples such as Next.js, Expo, and agents demos
  • commands should work from inside example/
  1. Recommended when shipping the example: add it to the root README.md.

diff --git a/.cursor/skills/scaffold-elevenlabs-example/reference.md b/.cursor/skills/scaffold-elevenlabs-example/reference.md
--- a/.cursor/skills/scaffold-elevenlabs-example/reference.md
+++ b/.cursor/skills/scaffold-elevenlabs-example/reference.md
@@ -60,7 +60,14 @@
| typescript | templates/typescript/ | node_modules | .env | pnpm install --config.confirmModulesPurge=false |
| python | templates/python/ | .venv | .env | create .venv, upgrade pip, pip install -r requirements.txt |
| nextjs | templates/nextjs/ | node_modules, .next | .env.local | patch package.json, then pnpm install --config.confirmModulesPurge=false |
+| expo | templates/expo/ | node_modules, .expo | .env | pnpm install --config.confirmModulesPurge=false |

+## Expo runtime notes
+
+- templates/expo/ should provide the generic Expo Router app shell, web.output: "server" config, a baseline /api/health route, and reusable verification scripts such as typecheck and export:web.
+- Keep Expo PROMPT.md files focused on ElevenLabs-specific UI, SDK usage, token exchange, +api.ts routes, and error handling instead of generic app bootstrapping.
+- Until the repo has dedicated Expo examples, use the closest same-product nextjs example as the authoring reference for Expo scaffolds.
+

Prompt rules

  • Start with Before writing any code, invoke the \/skill-name` skill...`.
    @@ -69,11 +76,12 @@
  • Keep prompts short and implementation-focused. Current prompts are direct checklists, not essays.
  • Mention the concrete SDK client, env loading, output format, model ids, voice ids, API route security, and UI behavior when those details are known.
  • Do not repeat repo-wide context that the generator already injects.
    +- For expo, assume the shared template already includes the app shell and baseline checks; only prompt for ElevenLabs-specific changes.

README rules

  • Always include a title, one-sentence summary, ## Setup, and ## Run.
    -- Add ## Usage for interactive or multi-step examples such as Next.js and agents demos.
    +- Add ## Usage for interactive or multi-step examples such as Next.js, Expo, and agents demos.
  • Keep commands valid from inside example/.
  • Use the closest current example as the formatting reference.

@@ -85,6 +93,7 @@

  • CLI transcription or file-based Scribe example: start from the speech-to-text quickstarts.
  • Realtime microphone UI: start from speech-to-text/nextjs/realtime.
  • Voice agent creation and conversation UI: start from agents/nextjs/quickstart.
    +- First Expo full-stack example for a product: start from the closest same-product nextjs example until a dedicated Expo reference exists.
  • For specialized agent behavior, start from agents/nextjs/quickstart and consult agents/nextjs/guardrails only as an existing reference, not as a scaffold mode.

Scaffold helper

@@ -93,7 +102,7 @@

python3 .cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py \
-  --path agents/nextjs/my-agent-demo
+  --path agents/expo/my-agent-demo

Useful flags:

diff --git a/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py b/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py
--- a/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py
+++ b/.cursor/skills/scaffold-elevenlabs-example/scripts/scaffold_example.py
@@ -18,11 +18,12 @@
parser.add_argument(
"--path",
required=True,

  •    help="Relative path like product/runtime/my-example",
    
  •    help="Relative path like product/runtime/my-example "
    
  •    "(for example, text-to-speech/expo/my-example).",
    
    )
    parser.add_argument(
    "--reference",
  •    help="Explicit reference example path (e.g. music/nextjs/quickstart). "
    
  •    help="Explicit reference example path (e.g. speech-to-text/nextjs/realtime). "
       "Auto-detected from the repo when omitted.",
    
    )
    parser.add_argument(
    @@ -44,7 +45,7 @@
    if len(parts) != 3:
    raise SystemExit(
    "Example paths must look like //, for example "
  •        "text-to-speech/nextjs/my-example."
    
  •        "text-to-speech/expo/my-example."
       )
    

    product, runtime, slug = parts
    @@ -70,23 +71,44 @@
    return results

+def pick_reference(paths: list[Path]) -> Path | None:

  • if not paths:
  •    return None
    
  • return sorted(paths, key=lambda path: (path.name != "quickstart", str(path)))[0]

def find_reference(
product: str, runtime: str, examples: list[tuple[str, str, str, Path]]
) -> Path | None:
"""Pick the best existing example to copy from.

  • Priority: same product+runtime > same runtime > first available.
  • Priority: same product+runtime > same runtime > same product+nextjs for
  • Expo > any nextjs for Expo > first available.
    """
    same_product_runtime = [d for p, r, _, d in examples if p == product and r == runtime]
  • if same_product_runtime:
  •    return same_product_runtime[0]
    
  • match = pick_reference(same_product_runtime)

  • if match:

  •    return match
    

    same_runtime = [d for _, r, _, d in examples if r == runtime]

  • if same_runtime:
  •    return same_runtime[0]
    
  • match = pick_reference(same_runtime)

  • if match:

  •    return match
    
  • if runtime == "expo":

  •    same_product_nextjs = [d for p, r, _, d in examples if p == product and r == "nextjs"]
    
  •    match = pick_reference(same_product_nextjs)
    
  •    if match:
    
  •        return match
    
  •    nextjs_examples = [d for _, r, _, d in examples if r == "nextjs"]
    
  •    match = pick_reference(nextjs_examples)
    
  •    if match:
    
  •        return match
    
  • if examples:

  •    return examples[0][3]
    
  •    return pick_reference([example_dir for _, _, _, example_dir in examples])
    

    return None

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@

  • setup.sh — scaffolds the example/ directory from a shared template
  • example/ — the generated, runnable example with its own README.md

-Shared base templates live in templates/ (Next.js, Python, TypeScript). UI styling rules are in DESIGN.md.
+Shared base templates live in templates/ (Expo, Next.js, Python, TypeScript). UI styling rules are in DESIGN.md.

The legacy examples/ folder is being deprecated and can be ignored for new work.

diff --git a/agents/expo/quickstart/PROMPT.md b/agents/expo/quickstart/PROMPT.md
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/PROMPT.md
@@ -1,0 +1,29 @@
+Before writing any code, invoke the /agents skill to learn the correct ElevenLabs SDK patterns.
+
+## app/api/agent+api.ts
+
+Secure Expo Router API route that creates or loads a voice agent. Never expose ELEVENLABS_API_KEY to the client.
+
+- POST creates a new voice agent with sensible defaults (name, system prompt, first message, TTS voice). Use the CLI voice-only template as reference for the agent shape.
+- GET loads an existing agent by agentId query param.
+- Configure as voice-first: real TTS voice and model, text-only disabled, widget text input disabled.
+- For English agents (language: "en"), use tts.modelId: "eleven_flash_v2". Do not use eleven_flash_v2_5 for English-only agents, or agent creation may fail validation.
+- Enable client events needed for transcript rendering and audio.
+- Return { agentId, agentName }.
+
+## app/api/conversation-token+api.ts
+
+Secure GET endpoint that returns a signed WebSocket URL for a given agentId using getSignedUrl.
+Never expose ELEVENLABS_API_KEY to the client. Return { signedUrl } as JSON.
+Do NOT use getWebrtcToken — WebRTC does not work reliably in the Expo web runtime.
+
+## app/index.tsx
+
+Minimal Expo Router voice agent screen.
+
+- Use @elevenlabs/react and the useConversation hook for the web experience.
+- Show a Create Agent button and an editable agent-id input. Auto-populate on create; allow pasting a different id to load it instead.
+- Start sessions with a signed URL from /api/conversation-token using startSession({ signedUrl }). Do NOT pass connectionType: "webrtc". Request mic access before starting.
+- Show a Start/Stop toggle, connection status, and running conversation transcript (append messages, don't replace).
+- Handle errors gracefully and allow reconnect. Keep the UI simple and voice-first.
+- Keep the verified path web-first: use relative fetch calls for Expo web, and render a brief native fallback note instead of attempting an unsupported in-app server flow.

diff --git a/agents/expo/quickstart/README.md b/agents/expo/quickstart/README.md
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/README.md
@@ -1,0 +1,39 @@
+# Real-Time Voice Agent (Expo)
+
+Live voice conversations with the ElevenLabs Agents Platform in an Expo Router app with secure Expo API routes for web.
+
+## Setup
+
+1. Copy the environment file and add your credentials:
+

  • cp .env.example .env
  • Then edit .env and set:
    • ELEVENLABS_API_KEY

+2. Install dependencies:
+

  • pnpm install

+## Run
+
+bash +pnpm run web +
+
+Open the local Expo web URL shown in the terminal.
+
+## Usage
+
+- Enter an agent name and a system prompt, then click Create agent.
+- The app creates the agent server-side and stores the returned agent id in the page.
+- Click Start and allow microphone access when prompted.
+- The app fetches a fresh conversation token for the created agent and starts a WebRTC session.
+- Speak naturally and watch the live conversation state update as the agent listens and responds.
+- The page shows whether the agent is currently speaking and renders the interaction as a running conversation.
+- Click Stop to end the session.
+- This quickstart is verified for Expo web. Native builds need a deployed Expo server origin before the in-app client can call the secure API routes.

diff --git a/agents/expo/quickstart/example/.env.example b/agents/expo/quickstart/example/.env.example
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/.env.example
@@ -1,0 +1 @@
+ELEVENLABS_API_KEY=

diff --git a/agents/expo/quickstart/example/.gitignore b/agents/expo/quickstart/example/.gitignore
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/.gitignore
@@ -1,0 +1,7 @@
+
+# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
+# The following patterns were generated by expo-cli
+
+.expo/
+expo-env.d.ts
+# @EnD expo-cli
\ No newline at end of file

diff --git a/agents/expo/quickstart/example/README.md b/agents/expo/quickstart/example/README.md
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/README.md
@@ -1,0 +1,39 @@
+# Real-Time Voice Agent (Expo)
+
+Live voice conversations with the ElevenLabs Agents Platform in an Expo Router app with secure Expo API routes for web.
+
+## Setup
+
+1. Copy the environment file and add your credentials:
+

  • cp .env.example .env
  • Then edit .env and set:
    • ELEVENLABS_API_KEY

+2. Install dependencies:
+

  • pnpm install

+## Run
+
+bash +pnpm run web +
+
+Open the local Expo web URL shown in the terminal.
+
+## Usage
+
+- Enter an agent name and a system prompt, then click Create agent.
+- The app creates the agent server-side and stores the returned agent id in the page.
+- Click Start and allow microphone access when prompted.
+- The app fetches a fresh conversation token for the created agent and starts a WebRTC session.
+- Speak naturally and watch the live conversation state update as the agent listens and responds.
+- The page shows whether the agent is currently speaking and renders the interaction as a running conversation.
+- Click Stop to end the session.
+- This quickstart is verified for Expo web. Native builds need a deployed Expo server origin before the in-app client can call the secure API routes.

diff --git a/agents/expo/quickstart/example/app.json b/agents/expo/quickstart/example/app.json
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app.json
@@ -1,0 +1,16 @@
+{

  • "expo": {
  • "name": "Real-Time Voice Agent",
  • "slug": "realtime-voice-agent-expo",
  • "scheme": "realtime-voice-agent-expo",
  • "version": "1.0.0",
  • "orientation": "portrait",
  • "web": {
  •  "output": "server"
    
  • },
  • "plugins": ["expo-router"],
  • "experiments": {
  •  "typedRoutes": true
    
  • }
  • }
    +}

diff --git a/agents/expo/quickstart/example/app/_layout.tsx b/agents/expo/quickstart/example/app/_layout.tsx
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/_layout.tsx
@@ -1,0 +1,12 @@
+import { Stack } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+
+export default function RootLayout() {

  • return (
  •  <StatusBar style="dark" />
    
  •  <Stack screenOptions={{ headerShown: false }} />
    
  • );
    +}

diff --git a/agents/expo/quickstart/example/app/api/agent+api.ts b/agents/expo/quickstart/example/app/api/agent+api.ts
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/api/agent+api.ts
@@ -1,0 +1,103 @@
+import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
+import { ClientEvent } from "@elevenlabs/elevenlabs-js/api/types/ClientEvent";
+import type { ConversationalConfig } from "@elevenlabs/elevenlabs-js/api/types/ConversationalConfig";
+
+function getClient() {

  • const apiKey = process.env.ELEVENLABS_API_KEY;
  • if (!apiKey) {
  • return {
  •  error: Response.json(
    
  •    { error: "Missing ELEVENLABS_API_KEY" },
    
  •    { status: 500 }
    
  •  ),
    
  • };
  • }
  • return { client: new ElevenLabsClient({ apiKey }) };
    +}

+function voiceFirstConversationConfig(): ConversationalConfig {

  • return {
  • agent: {
  •  firstMessage: "Hello! How can I help you today?",
    
  •  language: "en",
    
  •  prompt: {
    
  •    prompt:
    
  •      "You are a helpful voice assistant. Keep replies concise and natural for spoken conversation.",
    
  •    llm: "gemini-2.0-flash",
    
  •    temperature: 0.7,
    
  •  },
    
  • },
  • tts: {
  •  voiceId: "JBFqnCBsd6RMkjVDRZzb",
    
  •  modelId: "eleven_flash_v2",
    
  • },
  • conversation: {
  •  textOnly: false,
    
  •  clientEvents: [
    
  •    ClientEvent.UserTranscript,
    
  •    ClientEvent.TentativeUserTranscript,
    
  •    ClientEvent.AgentResponse,
    
  •    ClientEvent.AgentChatResponsePart,
    
  •    ClientEvent.Audio,
    
  •  ],
    
  • },
  • };
    +}

+export async function POST() {

  • const res = getClient();
  • if ("error" in res) {
  • return res.error;
  • }
  • try {
  • const agentName = "Expo Voice Agent";
  • const created = await res.client.conversationalAi.agents.create({
  •  name: agentName,
    
  •  enableVersioning: true,
    
  •  conversationConfig: voiceFirstConversationConfig(),
    
  •  platformSettings: {
    
  •    widget: {
    
  •      textInputEnabled: false,
    
  •      supportsTextOnly: false,
    
  •      conversationModeToggleEnabled: false,
    
  •    },
    
  •  },
    
  • });
  • return Response.json({
  •  agentId: created.agentId,
    
  •  agentName,
    
  • });
  • } catch (e) {
  • const message = e instanceof Error ? e.message : "Failed to create agent";
  • return Response.json({ error: message }, { status: 500 });
  • }
    +}

+export async function GET(request: Request) {

  • const res = getClient();
  • if ("error" in res) {
  • return res.error;
  • }
  • const url = new URL(request.url);
  • const agentId = url.searchParams.get("agentId");
  • if (!agentId?.trim()) {
  • return Response.json(
  •  { error: "Missing agentId query parameter" },
    
  •  { status: 400 }
    
  • );
  • }
  • try {
  • const agent = await res.client.conversationalAi.agents.get(agentId.trim());
  • return Response.json({
  •  agentId: agent.agentId,
    
  •  agentName: agent.name,
    
  • });
  • } catch (e) {
  • const message = e instanceof Error ? e.message : "Failed to load agent";
  • return Response.json({ error: message }, { status: 500 });
  • }
    +}

diff --git a/agents/expo/quickstart/example/app/api/conversation-token+api.ts b/agents/expo/quickstart/example/app/api/conversation-token+api.ts
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/api/conversation-token+api.ts
@@ -1,0 +1,31 @@
+import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
+
+export async function GET(request: Request) {

  • const apiKey = process.env.ELEVENLABS_API_KEY;
  • if (!apiKey) {
  • return Response.json(
  •  { error: "Missing ELEVENLABS_API_KEY" },
    
  •  { status: 500 }
    
  • );
  • }
  • const url = new URL(request.url);
  • const agentId = url.searchParams.get("agentId");
  • if (!agentId?.trim()) {
  • return Response.json(
  •  { error: "Missing agentId query parameter" },
    
  •  { status: 400 }
    
  • );
  • }
  • try {
  • const client = new ElevenLabsClient({ apiKey });
  • const signed = await client.conversationalAi.conversations.getSignedUrl({
  •  agentId: agentId.trim(),
    
  • });
  • return Response.json({ signedUrl: signed.signedUrl });
  • } catch (e) {
  • const message = e instanceof Error ? e.message : "Failed to get signed URL";
  • return Response.json({ error: message }, { status: 500 });
  • }
    +}

diff --git a/agents/expo/quickstart/example/app/api/health+api.ts b/agents/expo/quickstart/example/app/api/health+api.ts
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/api/health+api.ts
@@ -1,0 +1,6 @@
+export function GET() {

  • return Response.json({
  • ok: true,
  • runtime: "expo-router-api",
  • });
    +}

diff --git a/agents/expo/quickstart/example/app/index.tsx b/agents/expo/quickstart/example/app/index.tsx
new file mode 100644
--- /dev/null
+++ b/agents/expo/quickstart/example/app/index.tsx
@@ -1,0 +1,438 @@
+import { useCallback, useState } from "react";
+import {

  • ActivityIndicator,
  • Platform,
  • Pressable,
  • ScrollView,
  • StyleSheet,
  • Text,
  • TextInput,
  • View,
    +} from "react-native";
    +import { SafeAreaView } from "react-native-safe-area-context";
    +import { ConversationProvider, useConversation } from "@elevenlabs/react";

+type TranscriptLine = {

  • key: string;
  • role: "user" | "agent";
  • text: string;
    +};

+function VoiceAgentPanel() {

  • const [agentId, setAgentId] = useState("");
  • const [agentName, setAgentName] = useState<string | null>(null);
  • const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
  • const [apiError, setApiError] = useState<string | null>(null);
  • const [busy, setBusy] = useState<"idle" | "create" | "load" | "token">(
  • "idle"
  • );
  • const {
  • startSession,
  • endSession,
  • status,
  • message: statusMessage,
  • } = useConversation({
  • onMessage: props => {
  •  setTranscript(prev => [
    
  •    ...prev,
    
  •    {
    
  •      key: `${props.event_id ?? Date.now()}-${prev.length}`,
    
  •      role: props.role,
    
  •      text: props.message,
    
  •    },
    
  •  ]);
    
  • },
  • });
  • const clearConversationError = useCallback(() => {
  • setApiError(null);
  • }, []);
  • const createAgent = useCallback(async () => {
  • setBusy("create");
  • setApiError(null);
  • try {
  •  const response = await fetch("/api/agent", { method: "POST" });
    
  •  const data = (await response.json()) as {
    
  •    agentId?: string;
    
  •    agentName?: string;
    
  •    error?: string;
    
  •  };
    
  •  if (!response.ok) {
    
  •    throw new Error(data.error ?? `Request failed (${response.status})`);
    
  •  }
    
  •  if (!data.agentId) {
    
  •    throw new Error("Missing agentId in response");
    
  •  }
    
  •  setAgentId(data.agentId);
    
  •  setAgentName(data.agentName ?? null);
    
  • } catch (e) {
  •  setApiError(e instanceof Error ? e.message : "Failed to create agent");
    
  • } finally {
  •  setBusy("idle");
    
  • }
  • }, []);
  • const loadAgent = useCallback(async () => {
  • if (!agentId.trim()) {
  •  setApiError("Enter an agent id first.");
    
  •  return;
    
  • }
  • setBusy("load");
  • setApiError(null);
  • try {
  •  const response = await fetch(
    
  •    `/api/agent?agentId=${encodeURIComponent(agentId.trim())}`
    
  •  );
    
  •  const data = (await response.json()) as {
    
  •    agentId?: string;
    
  •    agentName?: string;
    
  •    error?: string;
    
  •  };
    
  •  if (!response.ok) {
    
  •    throw new Error(data.error ?? `Request failed (${response.status})`);
    
  •  }
    
  •  setAgentName(data.agentName ?? null);
    
  • } catch (e) {
  •  setApiError(e instanceof Error ? e.message : "Failed to load agent");
    
  • } finally {
  •  setBusy("idle");
    
  • }
  • }, [agentId]);
  • const startVoice = useCallback(async () => {
  • if (!agentId.trim()) {
  •  setApiError("Enter or create an agent id first.");
    
  •  return;
    
  • }
  • setBusy("token");
  • setApiError(null);
  • setTranscript([]);
  • try {
  •  if (
    
  •    typeof navigator !== "undefined" &&
    
  •    navigator.mediaDevices?.getUserMedia
    
  •  ) {
    
  •    await navigator.mediaDevices.getUserMedia({ audio: true });
    
  •  }
    
  •  const response = await fetch(
    
  •    `/api/conversation-token?agentId=${encodeURIComponent(agentId.trim())}`
    
  •  );
    
  •  const data = (await response.json()) as {
    
  •    signedUrl?: string;
    
  •    error?: string;
    
  •  };
    
  •  if (!response.ok) {
    
  •    throw new Error(data.error ?? `Request failed (${response.status})`);
    
  •  }
    
  •  if (!data.signedUrl) {
    
  •    throw new Error("Missing signedUrl in response");
    
  •  }
    
  •  await startSession({ signedUrl: data.signedUrl });
    
  • } catch (e) {
  •  setApiError(e instanceof Error ? e.message : "Failed to start session");
    
  • } finally {
  •  setBusy("idle");
    
  • }
  • }, [agentId, startSession]);
  • const stopVoice = useCallback(() => {
  • setApiError(null);
  • void endSession();
  • }, [endSession]);
  • const isBusy = busy !== "idle";
  • const isConnected = status === "connected";
  • const canStart = !isBusy && !isConnected && !!agentId.trim();
  • const primaryDisabled = isConnected ? false : !canStart || isBusy;
  • return (
  •  <Text style={styles.label}>Agent ID</Text>
    
  •  <TextInput
    
  •    autoCapitalize="none"
    
  •    autoCorrect={false}
    
  •    onChangeText={t => {
    
  •      setAgentId(t);
    
  •      clearConversationError();
    
  •    }}
    
  •    placeholder="Paste or create an agent id"
    
  •    style={styles.input}
    
  •    value={agentId}
    
  •  />
    
  •  <View style={styles.row}>
    
  •    <Pressable
    
  •      accessibilityRole="button"
    
  •      disabled={isBusy}
    
  •      onPress={createAgent}
    
  •      style={({ pressed }) => [
    
  •        styles.buttonSecondary,
    
  •        (pressed || isBusy) && styles.buttonPressed,
    
  •      ]}
    
  •    >
    
  •      {busy === "create" ? (
    
  •        <ActivityIndicator color="#171717" />
    
  •      ) : (
    
  •        <Text style={styles.buttonSecondaryText}>Create Agent</Text>
    
  •      )}
    
  •    </Pressable>
    
  •    <Pressable
    
  •      accessibilityRole="button"
    
  •      disabled={isBusy}
    
  •      onPress={loadAgent}
    
  •      style={({ pressed }) => [
    
  •        styles.buttonSecondary,
    
  •        (pressed || isBusy) && styles.buttonPressed,
    
  •      ]}
    
  •    >
    
  •      {busy === "load" ? (
    
  •        <ActivityIndicator color="#171717" />
    
  •      ) : (
    
  •        <Text style={styles.buttonSecondaryText}>Load agent</Text>
    
  •      )}
    
  •    </Pressable>
    
  •  </View>
    
  •  {agentName ? <Text style={styles.meta}>Loaded: {agentName}</Text> : null}
    
  •  <Pressable
    
  •    accessibilityRole="button"
    
  •    disabled={primaryDisabled}
    
  •    onPress={isConnected ? stopVoice : startVoice}
    
  •    style={({ pressed }) => [
    
  •      styles.buttonPrimary,
    
  •      (pressed || (isBusy && busy === "token")) && styles.buttonPressed,
    
  •      primaryDisabled ? styles.buttonDisabled : null,
    
  •    ]}
    
  •  >
    
  •    {busy === "token" ? (
    
  •      <ActivityIndicator color="#ffffff" />
    
  •    ) : (
    
  •      <Text style={styles.buttonText}>
    
  •        {isConnected ? "Stop" : "Start"}
    
  •      </Text>
    
  •    )}
    
  •  </Pressable>
    
  •  <Text style={styles.statusLabel}>Status</Text>
    
  •  <Text style={styles.status}>
    
  •    {status}
    
  •    {statusMessage ? ` — ${statusMessage}` : ""}
    
  •  </Text>
    
  •  {apiError ? <Text style={styles.error}>{apiError}</Text> : null}
    
  •  <Text style={styles.transcriptLabel}>Transcript</Text>
    
  •  <ScrollView style={styles.transcriptBox}>
    
  •    {transcript.length === 0 ? (
    
  •      <Text style={styles.transcriptEmpty}>
    
  •        Messages appear here during a conversation.
    
  •      </Text>
    
  •    ) : (
    
  •      transcript.map(line => (
    
  •        <Text key={line.key} style={styles.transcriptLine}>
    
  •          <Text style={styles.transcriptRole}>
    
  •            {line.role === "user" ? "You" : "Agent"}:{" "}
    
  •          </Text>
    
  •          {line.text}
    
  •        </Text>
    
  •      ))
    
  •    )}
    
  •  </ScrollView>
    
  • );
    +}

+export default function HomeScreen() {

  • const [providerError, setProviderError] = useState<string | null>(null);
  • if (Platform.OS !== "web") {
  • return (
  •  <SafeAreaView style={styles.screen}>
    
  •    <View style={styles.container}>
    
  •      <Text style={styles.eyebrow}>Expo Template</Text>
    
  •      <Text style={styles.title}>Voice agent (web)</Text>
    
  •      <Text style={styles.description}>
    
  •        This example uses Expo Router API routes and the ElevenLabs web
    
  •        conversation client. Run the app with{" "}
    
  •        <Text style={styles.descriptionEm}>npx expo start --web</Text> to
    
  •        try voice on the web build.
    
  •      </Text>
    
  •    </View>
    
  •  </SafeAreaView>
    
  • );
  • }
  • return (

... diff truncated: showing 800 of 1444 lines


</details>


<sub>You can send follow-ups to the cloud agent <a href="https://cursor.com/agents/bc-30316f98-4f02-4135-8261-4ba1d194ea27">here</a>.</sub>
<!-- BUGBOT_AUTOFIX_REVIEW_FOOTNOTE_END -->

<sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 169a1bcb45b81a1c2a6a90f6f304aa1ffaf71787. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>

Comment thread agents/expo/quickstart/example/app/index.tsx Outdated
@tadaspetra tadaspetra merged commit ea546ac into main Apr 14, 2026
3 checks passed
@tadaspetra tadaspetra deleted the react-native-example branch April 14, 2026 15:02
@tadaspetra tadaspetra restored the react-native-example branch April 14, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants