Conversation
There was a problem hiding this comment.
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-exampleAdd --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 orDESIGN.md; the generator adds that
+- forexpo, 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.tswork
- Keep
setup.shaligned with current patterns:
- use
set -euo pipefail - derive
DIRandREPO_ROOT
-- cleanexample/but preserve cache dirs (node_modules,.venv,.next) when relevant
+- cleanexample/but preserve cache dirs (node_modules,.venv,.next,.expo) when relevant - seed from
templates/<runtime>/ - copy
README.mdintoexample/README.md - copy
assets/and local.envonly when present - install dependencies at the end
- for
nextjs, fetch latest ElevenLabs package versions at setup time and patchpackage.json
+- forexpo, keep the shared template generic and server-capable soPROMPT.mdonly needs to describe the ElevenLabs integration
- Keep
README.mdaligned with the closest current reference:
- always include a heading, one-sentence summary,
## Setup, and## Run
-- add## Usagefor interactive examples such as Next.js and agents demos
+- add## Usagefor interactive examples such as Next.js, Expo, and agents demos - commands should work from inside
example/
- 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.
+- Forexpo, 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## Usagefor interactive or multi-step examples such as Next.js and agents demos.
+- Add## Usagefor 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-productnextjsexample until a dedicated Expo reference exists. - For specialized agent behavior, start from
agents/nextjs/quickstartand consultagents/nextjs/guardrailsonly 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-demoUseful 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 matchsame_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 theexample/directory from a shared templateexample/— the generated, runnable example with its ownREADME.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
.envand 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
.envand 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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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.containerwrapper fromVoiceAgentPanelso the form now shares the parent container padding and aligns with the header.
- Removed the inner
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-exampleAdd --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 orDESIGN.md; the generator adds that
+- forexpo, 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.tswork
- Keep
setup.shaligned with current patterns:
- use
set -euo pipefail - derive
DIRandREPO_ROOT
-- cleanexample/but preserve cache dirs (node_modules,.venv,.next) when relevant
+- cleanexample/but preserve cache dirs (node_modules,.venv,.next,.expo) when relevant - seed from
templates/<runtime>/ - copy
README.mdintoexample/README.md - copy
assets/and local.envonly when present - install dependencies at the end
- for
nextjs, fetch latest ElevenLabs package versions at setup time and patchpackage.json
+- forexpo, keep the shared template generic and server-capable soPROMPT.mdonly needs to describe the ElevenLabs integration
- Keep
README.mdaligned with the closest current reference:
- always include a heading, one-sentence summary,
## Setup, and## Run
-- add## Usagefor interactive examples such as Next.js and agents demos
+- add## Usagefor interactive examples such as Next.js, Expo, and agents demos - commands should work from inside
example/
- 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.
+- Forexpo, 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## Usagefor interactive or multi-step examples such as Next.js and agents demos.
+- Add## Usagefor 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-productnextjsexample until a dedicated Expo reference exists. - For specialized agent behavior, start from
agents/nextjs/quickstartand consultagents/nextjs/guardrailsonly 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-demoUseful 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 matchsame_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 theexample/directory from a shared templateexample/— the generated, runnable example with its ownREADME.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
.envand 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
.envand 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>

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/healthroute, and baseline scripts).Introduces a new Agents Expo quickstart (
agents/expo/quickstart) with asetup.shthat seeds from the Expo template, patches in latest@elevenlabs/react/@elevenlabs/elevenlabs-js, and anexample/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.pyto recognizeexpo, preserve.expoon 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.